mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	feat: locked/private view (#18268)
* feat: locked/private view * feat: locked/private view * pr feedback * fix: redirect loop * pr feedback
This commit is contained in:
		
							parent
							
								
									4935f3e0bb
								
							
						
					
					
						commit
						b7b0b9b6d8
					
				
							
								
								
									
										16
									
								
								i18n/en.json
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								i18n/en.json
									
									
									
									
									
								
							@ -1,4 +1,19 @@
 | 
			
		||||
{
 | 
			
		||||
  "new_pin_code_subtitle": "This is your first time accessing the locked folder. Create a PIN code to securely access this page",
 | 
			
		||||
  "enter_your_pin_code": "Enter your PIN code",
 | 
			
		||||
  "enter_your_pin_code_subtitle": "Enter your PIN code to access the locked folder",
 | 
			
		||||
  "pin_verification": "PIN code verification",
 | 
			
		||||
  "wrong_pin_code": "Wrong PIN code",
 | 
			
		||||
  "nothing_here_yet": "Nothing here yet",
 | 
			
		||||
  "move_to_locked_folder": "Move to Locked Folder",
 | 
			
		||||
  "remove_from_locked_folder": "Remove from Locked Folder",
 | 
			
		||||
  "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the Locked Folder",
 | 
			
		||||
  "remove_from_locked_folder_confirmation": "Are you sure you want to move these photos and videos out of Locked Folder? They will be visible in your library",
 | 
			
		||||
  "move": "Move",
 | 
			
		||||
  "no_locked_photos_message": "Photos and videos in Locked Folder are hidden and won't show up as you browser your library.",
 | 
			
		||||
  "locked_folder": "Locked Folder",
 | 
			
		||||
  "add_to_locked_folder": "Add to Locked Folder",
 | 
			
		||||
  "move_off_locked_folder": "Move out of Locked Folder",
 | 
			
		||||
  "user_pin_code_settings": "PIN Code",
 | 
			
		||||
  "user_pin_code_settings_description": "Manage your PIN code",
 | 
			
		||||
  "current_pin_code": "Current PIN code",
 | 
			
		||||
@ -837,6 +852,7 @@
 | 
			
		||||
  "error_saving_image": "Error: {error}",
 | 
			
		||||
  "error_title": "Error - Something went wrong",
 | 
			
		||||
  "errors": {
 | 
			
		||||
    "unable_to_move_to_locked_folder": "Unable to move to locked folder",
 | 
			
		||||
    "cannot_navigate_next_asset": "Cannot navigate to the next asset",
 | 
			
		||||
    "cannot_navigate_previous_asset": "Cannot navigate to previous asset",
 | 
			
		||||
    "cant_apply_changes": "Can't apply changes",
 | 
			
		||||
 | 
			
		||||
@ -29,6 +29,7 @@ dynamic upgradeDto(dynamic value, String targetType) {
 | 
			
		||||
    case 'UserResponseDto':
 | 
			
		||||
      if (value is Map) {
 | 
			
		||||
        addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
 | 
			
		||||
        addDefault(value, 'visibility', AssetVisibility.timeline);
 | 
			
		||||
      }
 | 
			
		||||
      break;
 | 
			
		||||
    case 'UserAdminResponseDto':
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							@ -117,6 +117,7 @@ Class | Method | HTTP request | Description
 | 
			
		||||
*AuthenticationApi* | [**setupPinCode**](doc//AuthenticationApi.md#setuppincode) | **POST** /auth/pin-code | 
 | 
			
		||||
*AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | 
 | 
			
		||||
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | 
 | 
			
		||||
*AuthenticationApi* | [**verifyPinCode**](doc//AuthenticationApi.md#verifypincode) | **POST** /auth/pin-code/verify | 
 | 
			
		||||
*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | 
 | 
			
		||||
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | 
 | 
			
		||||
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | 
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										39
									
								
								mobile/openapi/lib/api/authentication_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										39
									
								
								mobile/openapi/lib/api/authentication_api.dart
									
									
									
										generated
									
									
									
								
							@ -396,4 +396,43 @@ class AuthenticationApi {
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Performs an HTTP 'POST /auth/pin-code/verify' operation and returns the [Response].
 | 
			
		||||
  /// Parameters:
 | 
			
		||||
  ///
 | 
			
		||||
  /// * [PinCodeSetupDto] pinCodeSetupDto (required):
 | 
			
		||||
  Future<Response> verifyPinCodeWithHttpInfo(PinCodeSetupDto pinCodeSetupDto,) async {
 | 
			
		||||
    // ignore: prefer_const_declarations
 | 
			
		||||
    final apiPath = r'/auth/pin-code/verify';
 | 
			
		||||
 | 
			
		||||
    // ignore: prefer_final_locals
 | 
			
		||||
    Object? postBody = pinCodeSetupDto;
 | 
			
		||||
 | 
			
		||||
    final queryParams = <QueryParam>[];
 | 
			
		||||
    final headerParams = <String, String>{};
 | 
			
		||||
    final formParams = <String, String>{};
 | 
			
		||||
 | 
			
		||||
    const contentTypes = <String>['application/json'];
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    return apiClient.invokeAPI(
 | 
			
		||||
      apiPath,
 | 
			
		||||
      'POST',
 | 
			
		||||
      queryParams,
 | 
			
		||||
      postBody,
 | 
			
		||||
      headerParams,
 | 
			
		||||
      formParams,
 | 
			
		||||
      contentTypes.isEmpty ? null : contentTypes.first,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Parameters:
 | 
			
		||||
  ///
 | 
			
		||||
  /// * [PinCodeSetupDto] pinCodeSetupDto (required):
 | 
			
		||||
  Future<void> verifyPinCode(PinCodeSetupDto pinCodeSetupDto,) async {
 | 
			
		||||
    final response = await verifyPinCodeWithHttpInfo(pinCodeSetupDto,);
 | 
			
		||||
    if (response.statusCode >= HttpStatus.badRequest) {
 | 
			
		||||
      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										94
									
								
								mobile/openapi/lib/model/asset_response_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										94
									
								
								mobile/openapi/lib/model/asset_response_dto.dart
									
									
									
										generated
									
									
									
								
							@ -43,6 +43,7 @@ class AssetResponseDto {
 | 
			
		||||
    required this.type,
 | 
			
		||||
    this.unassignedFaces = const [],
 | 
			
		||||
    required this.updatedAt,
 | 
			
		||||
    required this.visibility,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  /// base64 encoded sha1 hash
 | 
			
		||||
@ -132,6 +133,8 @@ class AssetResponseDto {
 | 
			
		||||
 | 
			
		||||
  DateTime updatedAt;
 | 
			
		||||
 | 
			
		||||
  AssetResponseDtoVisibilityEnum visibility;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
 | 
			
		||||
    other.checksum == checksum &&
 | 
			
		||||
@ -163,7 +166,8 @@ class AssetResponseDto {
 | 
			
		||||
    other.thumbhash == thumbhash &&
 | 
			
		||||
    other.type == type &&
 | 
			
		||||
    _deepEquality.equals(other.unassignedFaces, unassignedFaces) &&
 | 
			
		||||
    other.updatedAt == updatedAt;
 | 
			
		||||
    other.updatedAt == updatedAt &&
 | 
			
		||||
    other.visibility == visibility;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode =>
 | 
			
		||||
@ -197,10 +201,11 @@ class AssetResponseDto {
 | 
			
		||||
    (thumbhash == null ? 0 : thumbhash!.hashCode) +
 | 
			
		||||
    (type.hashCode) +
 | 
			
		||||
    (unassignedFaces.hashCode) +
 | 
			
		||||
    (updatedAt.hashCode);
 | 
			
		||||
    (updatedAt.hashCode) +
 | 
			
		||||
    (visibility.hashCode);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt]';
 | 
			
		||||
  String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]';
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    final json = <String, dynamic>{};
 | 
			
		||||
@ -270,6 +275,7 @@ class AssetResponseDto {
 | 
			
		||||
      json[r'type'] = this.type;
 | 
			
		||||
      json[r'unassignedFaces'] = this.unassignedFaces;
 | 
			
		||||
      json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
 | 
			
		||||
      json[r'visibility'] = this.visibility;
 | 
			
		||||
    return json;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -312,6 +318,7 @@ class AssetResponseDto {
 | 
			
		||||
        type: AssetTypeEnum.fromJson(json[r'type'])!,
 | 
			
		||||
        unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
 | 
			
		||||
        updatedAt: mapDateTime(json, r'updatedAt', r'')!,
 | 
			
		||||
        visibility: AssetResponseDtoVisibilityEnum.fromJson(json[r'visibility'])!,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
@ -378,6 +385,87 @@ class AssetResponseDto {
 | 
			
		||||
    'thumbhash',
 | 
			
		||||
    'type',
 | 
			
		||||
    'updatedAt',
 | 
			
		||||
    'visibility',
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AssetResponseDtoVisibilityEnum {
 | 
			
		||||
  /// Instantiate a new enum with the provided [value].
 | 
			
		||||
  const AssetResponseDtoVisibilityEnum._(this.value);
 | 
			
		||||
 | 
			
		||||
  /// The underlying value of this enum member.
 | 
			
		||||
  final String value;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() => value;
 | 
			
		||||
 | 
			
		||||
  String toJson() => value;
 | 
			
		||||
 | 
			
		||||
  static const archive = AssetResponseDtoVisibilityEnum._(r'archive');
 | 
			
		||||
  static const timeline = AssetResponseDtoVisibilityEnum._(r'timeline');
 | 
			
		||||
  static const hidden = AssetResponseDtoVisibilityEnum._(r'hidden');
 | 
			
		||||
  static const locked = AssetResponseDtoVisibilityEnum._(r'locked');
 | 
			
		||||
 | 
			
		||||
  /// List of all possible values in this [enum][AssetResponseDtoVisibilityEnum].
 | 
			
		||||
  static const values = <AssetResponseDtoVisibilityEnum>[
 | 
			
		||||
    archive,
 | 
			
		||||
    timeline,
 | 
			
		||||
    hidden,
 | 
			
		||||
    locked,
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  static AssetResponseDtoVisibilityEnum? fromJson(dynamic value) => AssetResponseDtoVisibilityEnumTypeTransformer().decode(value);
 | 
			
		||||
 | 
			
		||||
  static List<AssetResponseDtoVisibilityEnum> listFromJson(dynamic json, {bool growable = false,}) {
 | 
			
		||||
    final result = <AssetResponseDtoVisibilityEnum>[];
 | 
			
		||||
    if (json is List && json.isNotEmpty) {
 | 
			
		||||
      for (final row in json) {
 | 
			
		||||
        final value = AssetResponseDtoVisibilityEnum.fromJson(row);
 | 
			
		||||
        if (value != null) {
 | 
			
		||||
          result.add(value);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return result.toList(growable: growable);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Transformation class that can [encode] an instance of [AssetResponseDtoVisibilityEnum] to String,
 | 
			
		||||
/// and [decode] dynamic data back to [AssetResponseDtoVisibilityEnum].
 | 
			
		||||
class AssetResponseDtoVisibilityEnumTypeTransformer {
 | 
			
		||||
  factory AssetResponseDtoVisibilityEnumTypeTransformer() => _instance ??= const AssetResponseDtoVisibilityEnumTypeTransformer._();
 | 
			
		||||
 | 
			
		||||
  const AssetResponseDtoVisibilityEnumTypeTransformer._();
 | 
			
		||||
 | 
			
		||||
  String encode(AssetResponseDtoVisibilityEnum data) => data.value;
 | 
			
		||||
 | 
			
		||||
  /// Decodes a [dynamic value][data] to a AssetResponseDtoVisibilityEnum.
 | 
			
		||||
  ///
 | 
			
		||||
  /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
 | 
			
		||||
  /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
 | 
			
		||||
  /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
 | 
			
		||||
  ///
 | 
			
		||||
  /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
 | 
			
		||||
  /// and users are still using an old app with the old code.
 | 
			
		||||
  AssetResponseDtoVisibilityEnum? decode(dynamic data, {bool allowNull = true}) {
 | 
			
		||||
    if (data != null) {
 | 
			
		||||
      switch (data) {
 | 
			
		||||
        case r'archive': return AssetResponseDtoVisibilityEnum.archive;
 | 
			
		||||
        case r'timeline': return AssetResponseDtoVisibilityEnum.timeline;
 | 
			
		||||
        case r'hidden': return AssetResponseDtoVisibilityEnum.hidden;
 | 
			
		||||
        case r'locked': return AssetResponseDtoVisibilityEnum.locked;
 | 
			
		||||
        default:
 | 
			
		||||
          if (!allowNull) {
 | 
			
		||||
            throw ArgumentError('Unknown enum value to decode: $data');
 | 
			
		||||
          }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Singleton [AssetResponseDtoVisibilityEnumTypeTransformer] instance.
 | 
			
		||||
  static AssetResponseDtoVisibilityEnumTypeTransformer? _instance;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										3
									
								
								mobile/openapi/lib/model/asset_visibility.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								mobile/openapi/lib/model/asset_visibility.dart
									
									
									
										generated
									
									
									
								
							@ -26,12 +26,14 @@ class AssetVisibility {
 | 
			
		||||
  static const archive = AssetVisibility._(r'archive');
 | 
			
		||||
  static const timeline = AssetVisibility._(r'timeline');
 | 
			
		||||
  static const hidden = AssetVisibility._(r'hidden');
 | 
			
		||||
  static const locked = AssetVisibility._(r'locked');
 | 
			
		||||
 | 
			
		||||
  /// List of all possible values in this [enum][AssetVisibility].
 | 
			
		||||
  static const values = <AssetVisibility>[
 | 
			
		||||
    archive,
 | 
			
		||||
    timeline,
 | 
			
		||||
    hidden,
 | 
			
		||||
    locked,
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  static AssetVisibility? fromJson(dynamic value) => AssetVisibilityTypeTransformer().decode(value);
 | 
			
		||||
@ -73,6 +75,7 @@ class AssetVisibilityTypeTransformer {
 | 
			
		||||
        case r'archive': return AssetVisibility.archive;
 | 
			
		||||
        case r'timeline': return AssetVisibility.timeline;
 | 
			
		||||
        case r'hidden': return AssetVisibility.hidden;
 | 
			
		||||
        case r'locked': return AssetVisibility.locked;
 | 
			
		||||
        default:
 | 
			
		||||
          if (!allowNull) {
 | 
			
		||||
            throw ArgumentError('Unknown enum value to decode: $data');
 | 
			
		||||
 | 
			
		||||
@ -13,30 +13,36 @@ part of openapi.api;
 | 
			
		||||
class AuthStatusResponseDto {
 | 
			
		||||
  /// Returns a new [AuthStatusResponseDto] instance.
 | 
			
		||||
  AuthStatusResponseDto({
 | 
			
		||||
    required this.isElevated,
 | 
			
		||||
    required this.password,
 | 
			
		||||
    required this.pinCode,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  bool isElevated;
 | 
			
		||||
 | 
			
		||||
  bool password;
 | 
			
		||||
 | 
			
		||||
  bool pinCode;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) => identical(this, other) || other is AuthStatusResponseDto &&
 | 
			
		||||
    other.isElevated == isElevated &&
 | 
			
		||||
    other.password == password &&
 | 
			
		||||
    other.pinCode == pinCode;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode =>
 | 
			
		||||
    // ignore: unnecessary_parenthesis
 | 
			
		||||
    (isElevated.hashCode) +
 | 
			
		||||
    (password.hashCode) +
 | 
			
		||||
    (pinCode.hashCode);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() => 'AuthStatusResponseDto[password=$password, pinCode=$pinCode]';
 | 
			
		||||
  String toString() => 'AuthStatusResponseDto[isElevated=$isElevated, password=$password, pinCode=$pinCode]';
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    final json = <String, dynamic>{};
 | 
			
		||||
      json[r'isElevated'] = this.isElevated;
 | 
			
		||||
      json[r'password'] = this.password;
 | 
			
		||||
      json[r'pinCode'] = this.pinCode;
 | 
			
		||||
    return json;
 | 
			
		||||
@ -51,6 +57,7 @@ class AuthStatusResponseDto {
 | 
			
		||||
      final json = value.cast<String, dynamic>();
 | 
			
		||||
 | 
			
		||||
      return AuthStatusResponseDto(
 | 
			
		||||
        isElevated: mapValueOfType<bool>(json, r'isElevated')!,
 | 
			
		||||
        password: mapValueOfType<bool>(json, r'password')!,
 | 
			
		||||
        pinCode: mapValueOfType<bool>(json, r'pinCode')!,
 | 
			
		||||
      );
 | 
			
		||||
@ -100,6 +107,7 @@ class AuthStatusResponseDto {
 | 
			
		||||
 | 
			
		||||
  /// The list of required keys that must be present in a JSON.
 | 
			
		||||
  static const requiredKeys = <String>{
 | 
			
		||||
    'isElevated',
 | 
			
		||||
    'password',
 | 
			
		||||
    'pinCode',
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										3
									
								
								mobile/openapi/lib/model/sync_asset_v1.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								mobile/openapi/lib/model/sync_asset_v1.dart
									
									
									
										generated
									
									
									
								
							@ -293,12 +293,14 @@ class SyncAssetV1VisibilityEnum {
 | 
			
		||||
  static const archive = SyncAssetV1VisibilityEnum._(r'archive');
 | 
			
		||||
  static const timeline = SyncAssetV1VisibilityEnum._(r'timeline');
 | 
			
		||||
  static const hidden = SyncAssetV1VisibilityEnum._(r'hidden');
 | 
			
		||||
  static const locked = SyncAssetV1VisibilityEnum._(r'locked');
 | 
			
		||||
 | 
			
		||||
  /// List of all possible values in this [enum][SyncAssetV1VisibilityEnum].
 | 
			
		||||
  static const values = <SyncAssetV1VisibilityEnum>[
 | 
			
		||||
    archive,
 | 
			
		||||
    timeline,
 | 
			
		||||
    hidden,
 | 
			
		||||
    locked,
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  static SyncAssetV1VisibilityEnum? fromJson(dynamic value) => SyncAssetV1VisibilityEnumTypeTransformer().decode(value);
 | 
			
		||||
@ -340,6 +342,7 @@ class SyncAssetV1VisibilityEnumTypeTransformer {
 | 
			
		||||
        case r'archive': return SyncAssetV1VisibilityEnum.archive;
 | 
			
		||||
        case r'timeline': return SyncAssetV1VisibilityEnum.timeline;
 | 
			
		||||
        case r'hidden': return SyncAssetV1VisibilityEnum.hidden;
 | 
			
		||||
        case r'locked': return SyncAssetV1VisibilityEnum.locked;
 | 
			
		||||
        default:
 | 
			
		||||
          if (!allowNull) {
 | 
			
		||||
            throw ArgumentError('Unknown enum value to decode: $data');
 | 
			
		||||
 | 
			
		||||
@ -2470,6 +2470,41 @@
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "/auth/pin-code/verify": {
 | 
			
		||||
      "post": {
 | 
			
		||||
        "operationId": "verifyPinCode",
 | 
			
		||||
        "parameters": [],
 | 
			
		||||
        "requestBody": {
 | 
			
		||||
          "content": {
 | 
			
		||||
            "application/json": {
 | 
			
		||||
              "schema": {
 | 
			
		||||
                "$ref": "#/components/schemas/PinCodeSetupDto"
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          "required": true
 | 
			
		||||
        },
 | 
			
		||||
        "responses": {
 | 
			
		||||
          "200": {
 | 
			
		||||
            "description": ""
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "security": [
 | 
			
		||||
          {
 | 
			
		||||
            "bearer": []
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "cookie": []
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "api_key": []
 | 
			
		||||
          }
 | 
			
		||||
        ],
 | 
			
		||||
        "tags": [
 | 
			
		||||
          "Authentication"
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "/auth/status": {
 | 
			
		||||
      "get": {
 | 
			
		||||
        "operationId": "getAuthStatus",
 | 
			
		||||
@ -9150,6 +9185,15 @@
 | 
			
		||||
          "updatedAt": {
 | 
			
		||||
            "format": "date-time",
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "visibility": {
 | 
			
		||||
            "enum": [
 | 
			
		||||
              "archive",
 | 
			
		||||
              "timeline",
 | 
			
		||||
              "hidden",
 | 
			
		||||
              "locked"
 | 
			
		||||
            ],
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "required": [
 | 
			
		||||
@ -9171,7 +9215,8 @@
 | 
			
		||||
          "ownerId",
 | 
			
		||||
          "thumbhash",
 | 
			
		||||
          "type",
 | 
			
		||||
          "updatedAt"
 | 
			
		||||
          "updatedAt",
 | 
			
		||||
          "visibility"
 | 
			
		||||
        ],
 | 
			
		||||
        "type": "object"
 | 
			
		||||
      },
 | 
			
		||||
@ -9226,7 +9271,8 @@
 | 
			
		||||
        "enum": [
 | 
			
		||||
          "archive",
 | 
			
		||||
          "timeline",
 | 
			
		||||
          "hidden"
 | 
			
		||||
          "hidden",
 | 
			
		||||
          "locked"
 | 
			
		||||
        ],
 | 
			
		||||
        "type": "string"
 | 
			
		||||
      },
 | 
			
		||||
@ -9241,6 +9287,9 @@
 | 
			
		||||
      },
 | 
			
		||||
      "AuthStatusResponseDto": {
 | 
			
		||||
        "properties": {
 | 
			
		||||
          "isElevated": {
 | 
			
		||||
            "type": "boolean"
 | 
			
		||||
          },
 | 
			
		||||
          "password": {
 | 
			
		||||
            "type": "boolean"
 | 
			
		||||
          },
 | 
			
		||||
@ -9249,6 +9298,7 @@
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "required": [
 | 
			
		||||
          "isElevated",
 | 
			
		||||
          "password",
 | 
			
		||||
          "pinCode"
 | 
			
		||||
        ],
 | 
			
		||||
@ -12664,7 +12714,8 @@
 | 
			
		||||
            "enum": [
 | 
			
		||||
              "archive",
 | 
			
		||||
              "timeline",
 | 
			
		||||
              "hidden"
 | 
			
		||||
              "hidden",
 | 
			
		||||
              "locked"
 | 
			
		||||
            ],
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
@ -329,6 +329,7 @@ export type AssetResponseDto = {
 | 
			
		||||
    "type": AssetTypeEnum;
 | 
			
		||||
    unassignedFaces?: AssetFaceWithoutPersonResponseDto[];
 | 
			
		||||
    updatedAt: string;
 | 
			
		||||
    visibility: Visibility;
 | 
			
		||||
};
 | 
			
		||||
export type AlbumResponseDto = {
 | 
			
		||||
    albumName: string;
 | 
			
		||||
@ -520,6 +521,7 @@ export type PinCodeSetupDto = {
 | 
			
		||||
    pinCode: string;
 | 
			
		||||
};
 | 
			
		||||
export type AuthStatusResponseDto = {
 | 
			
		||||
    isElevated: boolean;
 | 
			
		||||
    password: boolean;
 | 
			
		||||
    pinCode: boolean;
 | 
			
		||||
};
 | 
			
		||||
@ -2076,6 +2078,15 @@ export function changePinCode({ pinCodeChangeDto }: {
 | 
			
		||||
        body: pinCodeChangeDto
 | 
			
		||||
    })));
 | 
			
		||||
}
 | 
			
		||||
export function verifyPinCode({ pinCodeSetupDto }: {
 | 
			
		||||
    pinCodeSetupDto: PinCodeSetupDto;
 | 
			
		||||
}, opts?: Oazapfts.RequestOpts) {
 | 
			
		||||
    return oazapfts.ok(oazapfts.fetchText("/auth/pin-code/verify", oazapfts.json({
 | 
			
		||||
        ...opts,
 | 
			
		||||
        method: "POST",
 | 
			
		||||
        body: pinCodeSetupDto
 | 
			
		||||
    })));
 | 
			
		||||
}
 | 
			
		||||
export function getAuthStatus(opts?: Oazapfts.RequestOpts) {
 | 
			
		||||
    return oazapfts.ok(oazapfts.fetchJson<{
 | 
			
		||||
        status: 200;
 | 
			
		||||
@ -3574,7 +3585,8 @@ export enum UserStatus {
 | 
			
		||||
export enum AssetVisibility {
 | 
			
		||||
    Archive = "archive",
 | 
			
		||||
    Timeline = "timeline",
 | 
			
		||||
    Hidden = "hidden"
 | 
			
		||||
    Hidden = "hidden",
 | 
			
		||||
    Locked = "locked"
 | 
			
		||||
}
 | 
			
		||||
export enum AlbumUserRole {
 | 
			
		||||
    Editor = "editor",
 | 
			
		||||
@ -3591,6 +3603,12 @@ export enum AssetTypeEnum {
 | 
			
		||||
    Audio = "AUDIO",
 | 
			
		||||
    Other = "OTHER"
 | 
			
		||||
}
 | 
			
		||||
export enum Visibility {
 | 
			
		||||
    Archive = "archive",
 | 
			
		||||
    Timeline = "timeline",
 | 
			
		||||
    Hidden = "hidden",
 | 
			
		||||
    Locked = "locked"
 | 
			
		||||
}
 | 
			
		||||
export enum AssetOrder {
 | 
			
		||||
    Asc = "asc",
 | 
			
		||||
    Desc = "desc"
 | 
			
		||||
 | 
			
		||||
@ -101,4 +101,11 @@ export class AuthController {
 | 
			
		||||
  async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
 | 
			
		||||
    return this.service.resetPinCode(auth, dto);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Post('pin-code/verify')
 | 
			
		||||
  @HttpCode(HttpStatus.OK)
 | 
			
		||||
  @Authenticated()
 | 
			
		||||
  async verifyPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise<void> {
 | 
			
		||||
    return this.service.verifyPinCode(auth, dto);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -66,7 +66,7 @@ describe(SearchController.name, () => {
 | 
			
		||||
        .send({ visibility: 'immich' });
 | 
			
		||||
      expect(status).toBe(400);
 | 
			
		||||
      expect(body).toEqual(
 | 
			
		||||
        errorDto.badRequest(['visibility must be one of the following values: archive, timeline, hidden']),
 | 
			
		||||
        errorDto.badRequest(['visibility must be one of the following values: archive, timeline, hidden, locked']),
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -200,6 +200,7 @@ export type Album = Selectable<Albums> & {
 | 
			
		||||
 | 
			
		||||
export type AuthSession = {
 | 
			
		||||
  id: string;
 | 
			
		||||
  hasElevatedPermission: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type Partner = {
 | 
			
		||||
@ -233,6 +234,7 @@ export type Session = {
 | 
			
		||||
  updatedAt: Date;
 | 
			
		||||
  deviceOS: string;
 | 
			
		||||
  deviceType: string;
 | 
			
		||||
  pinExpiresAt: Date | null;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type Exif = Omit<Selectable<DatabaseExif>, 'updatedAt' | 'updateId'>;
 | 
			
		||||
@ -306,7 +308,7 @@ export const columns = {
 | 
			
		||||
    'users.quotaSizeInBytes',
 | 
			
		||||
  ],
 | 
			
		||||
  authApiKey: ['api_keys.id', 'api_keys.permissions'],
 | 
			
		||||
  authSession: ['sessions.id', 'sessions.updatedAt'],
 | 
			
		||||
  authSession: ['sessions.id', 'sessions.updatedAt', 'sessions.pinExpiresAt'],
 | 
			
		||||
  authSharedLink: [
 | 
			
		||||
    'shared_links.id',
 | 
			
		||||
    'shared_links.userId',
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								server/src/db.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								server/src/db.d.ts
									
									
									
									
										vendored
									
									
								
							@ -347,6 +347,7 @@ export interface Sessions {
 | 
			
		||||
  updatedAt: Generated<Timestamp>;
 | 
			
		||||
  updateId: Generated<string>;
 | 
			
		||||
  userId: string;
 | 
			
		||||
  pinExpiresAt: Timestamp | null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface SessionSyncCheckpoints {
 | 
			
		||||
 | 
			
		||||
@ -43,6 +43,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
 | 
			
		||||
  isArchived!: boolean;
 | 
			
		||||
  isTrashed!: boolean;
 | 
			
		||||
  isOffline!: boolean;
 | 
			
		||||
  visibility!: AssetVisibility;
 | 
			
		||||
  exifInfo?: ExifResponseDto;
 | 
			
		||||
  tags?: TagResponseDto[];
 | 
			
		||||
  people?: PersonWithFacesResponseDto[];
 | 
			
		||||
@ -184,6 +185,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
 | 
			
		||||
    isFavorite: options.auth?.user.id === entity.ownerId ? entity.isFavorite : false,
 | 
			
		||||
    isArchived: entity.visibility === AssetVisibility.ARCHIVE,
 | 
			
		||||
    isTrashed: !!entity.deletedAt,
 | 
			
		||||
    visibility: entity.visibility,
 | 
			
		||||
    duration: entity.duration ?? '0:00:00.00000',
 | 
			
		||||
    exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
 | 
			
		||||
    livePhotoVideoId: entity.livePhotoVideoId,
 | 
			
		||||
 | 
			
		||||
@ -138,4 +138,5 @@ export class OAuthAuthorizeResponseDto {
 | 
			
		||||
export class AuthStatusResponseDto {
 | 
			
		||||
  pinCode!: boolean;
 | 
			
		||||
  password!: boolean;
 | 
			
		||||
  isElevated!: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -627,4 +627,5 @@ export enum AssetVisibility {
 | 
			
		||||
   * Video part of the LivePhotos and MotionPhotos
 | 
			
		||||
   */
 | 
			
		||||
  HIDDEN = 'hidden',
 | 
			
		||||
  LOCKED = 'locked',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -98,6 +98,7 @@ from
 | 
			
		||||
where
 | 
			
		||||
  "assets"."id" in ($1)
 | 
			
		||||
  and "assets"."ownerId" = $2
 | 
			
		||||
  and "assets"."visibility" != $3
 | 
			
		||||
 | 
			
		||||
-- AccessRepository.asset.checkPartnerAccess
 | 
			
		||||
select
 | 
			
		||||
 | 
			
		||||
@ -392,6 +392,11 @@ where
 | 
			
		||||
order by
 | 
			
		||||
  "albums"."createdAt" desc
 | 
			
		||||
 | 
			
		||||
-- AlbumRepository.removeAssetsFromAll
 | 
			
		||||
delete from "albums_assets_assets"
 | 
			
		||||
where
 | 
			
		||||
  "albums_assets_assets"."assetsId" in ($1)
 | 
			
		||||
 | 
			
		||||
-- AlbumRepository.getAssetIds
 | 
			
		||||
select
 | 
			
		||||
  *
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,7 @@ where
 | 
			
		||||
select
 | 
			
		||||
  "sessions"."id",
 | 
			
		||||
  "sessions"."updatedAt",
 | 
			
		||||
  "sessions"."pinExpiresAt",
 | 
			
		||||
  (
 | 
			
		||||
    select
 | 
			
		||||
      to_json(obj)
 | 
			
		||||
 | 
			
		||||
@ -168,7 +168,7 @@ class AssetAccess {
 | 
			
		||||
 | 
			
		||||
  @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
 | 
			
		||||
  @ChunkedSet({ paramIndex: 1 })
 | 
			
		||||
  async checkOwnerAccess(userId: string, assetIds: Set<string>) {
 | 
			
		||||
  async checkOwnerAccess(userId: string, assetIds: Set<string>, hasElevatedPermission: boolean | undefined) {
 | 
			
		||||
    if (assetIds.size === 0) {
 | 
			
		||||
      return new Set<string>();
 | 
			
		||||
    }
 | 
			
		||||
@ -178,6 +178,7 @@ class AssetAccess {
 | 
			
		||||
      .select('assets.id')
 | 
			
		||||
      .where('assets.id', 'in', [...assetIds])
 | 
			
		||||
      .where('assets.ownerId', '=', userId)
 | 
			
		||||
      .$if(!hasElevatedPermission, (eb) => eb.where('assets.visibility', '!=', AssetVisibility.LOCKED))
 | 
			
		||||
      .execute()
 | 
			
		||||
      .then((assets) => new Set(assets.map((asset) => asset.id)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -220,8 +220,10 @@ export class AlbumRepository {
 | 
			
		||||
    await this.db.deleteFrom('albums').where('ownerId', '=', userId).execute();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async removeAsset(assetId: string): Promise<void> {
 | 
			
		||||
    await this.db.deleteFrom('albums_assets_assets').where('albums_assets_assets.assetsId', '=', assetId).execute();
 | 
			
		||||
  @GenerateSql({ params: [[DummyValue.UUID]] })
 | 
			
		||||
  @Chunked()
 | 
			
		||||
  async removeAssetsFromAll(assetIds: string[]): Promise<void> {
 | 
			
		||||
    await this.db.deleteFrom('albums_assets_assets').where('albums_assets_assets.assetsId', 'in', assetIds).execute();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Chunked({ paramIndex: 1 })
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,9 @@
 | 
			
		||||
import { Kysely, sql } from 'kysely';
 | 
			
		||||
 | 
			
		||||
export async function up(db: Kysely<any>): Promise<void> {
 | 
			
		||||
  await sql`ALTER TYPE "asset_visibility_enum" ADD VALUE IF NOT EXISTS 'locked';`.execute(db);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function down(): Promise<void> {
 | 
			
		||||
  // noop
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,9 @@
 | 
			
		||||
import { Kysely, sql } from 'kysely';
 | 
			
		||||
 | 
			
		||||
export async function up(db: Kysely<any>): Promise<void> {
 | 
			
		||||
  await sql`ALTER TABLE "sessions" ADD "pinExpiresAt" timestamp with time zone;`.execute(db);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function down(db: Kysely<any>): Promise<void> {
 | 
			
		||||
  await sql`ALTER TABLE "sessions" DROP COLUMN "pinExpiresAt";`.execute(db);
 | 
			
		||||
}
 | 
			
		||||
@ -36,4 +36,7 @@ export class SessionTable {
 | 
			
		||||
 | 
			
		||||
  @UpdateIdColumn({ indexName: 'IDX_sessions_update_id' })
 | 
			
		||||
  updateId!: string;
 | 
			
		||||
 | 
			
		||||
  @Column({ type: 'timestamp with time zone', nullable: true })
 | 
			
		||||
  pinExpiresAt!: Date | null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -163,7 +163,7 @@ describe(AlbumService.name, () => {
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      expect(mocks.user.get).toHaveBeenCalledWith('user-id', {});
 | 
			
		||||
      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']));
 | 
			
		||||
      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']), false);
 | 
			
		||||
      expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', {
 | 
			
		||||
        id: albumStub.empty.id,
 | 
			
		||||
        userId: 'user-id',
 | 
			
		||||
@ -207,6 +207,7 @@ describe(AlbumService.name, () => {
 | 
			
		||||
      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
 | 
			
		||||
        authStub.admin.user.id,
 | 
			
		||||
        new Set(['asset-1', 'asset-2']),
 | 
			
		||||
        false,
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
@ -688,7 +689,11 @@ describe(AlbumService.name, () => {
 | 
			
		||||
        { success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION },
 | 
			
		||||
      ]);
 | 
			
		||||
 | 
			
		||||
      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
 | 
			
		||||
      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
 | 
			
		||||
        authStub.admin.user.id,
 | 
			
		||||
        new Set(['asset-1']),
 | 
			
		||||
        false,
 | 
			
		||||
      );
 | 
			
		||||
      expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -481,7 +481,11 @@ describe(AssetMediaService.name, () => {
 | 
			
		||||
    it('should require the asset.download permission', async () => {
 | 
			
		||||
      await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
 | 
			
		||||
 | 
			
		||||
      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
 | 
			
		||||
      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
 | 
			
		||||
        authStub.admin.user.id,
 | 
			
		||||
        new Set(['asset-1']),
 | 
			
		||||
        undefined,
 | 
			
		||||
      );
 | 
			
		||||
      expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
 | 
			
		||||
      expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
 | 
			
		||||
    });
 | 
			
		||||
@ -512,7 +516,7 @@ describe(AssetMediaService.name, () => {
 | 
			
		||||
    it('should require asset.view permissions', async () => {
 | 
			
		||||
      await expect(sut.viewThumbnail(authStub.admin, 'id', {})).rejects.toBeInstanceOf(BadRequestException);
 | 
			
		||||
 | 
			
		||||
      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
 | 
			
		||||
      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined);
 | 
			
		||||
      expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
 | 
			
		||||
      expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
 | 
			
		||||
    });
 | 
			
		||||
@ -611,7 +615,7 @@ describe(AssetMediaService.name, () => {
 | 
			
		||||
    it('should require asset.view permissions', async () => {
 | 
			
		||||
      await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException);
 | 
			
		||||
 | 
			
		||||
      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
 | 
			
		||||
      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined);
 | 
			
		||||
      expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
 | 
			
		||||
      expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -122,6 +122,7 @@ describe(AssetService.name, () => {
 | 
			
		||||
      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
 | 
			
		||||
        authStub.admin.user.id,
 | 
			
		||||
        new Set([assetStub.image.id]),
 | 
			
		||||
        undefined,
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -14,7 +14,7 @@ import {
 | 
			
		||||
  mapStats,
 | 
			
		||||
} from 'src/dtos/asset.dto';
 | 
			
		||||
import { AuthDto } from 'src/dtos/auth.dto';
 | 
			
		||||
import { AssetStatus, JobName, JobStatus, Permission, QueueName } from 'src/enum';
 | 
			
		||||
import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum';
 | 
			
		||||
import { BaseService } from 'src/services/base.service';
 | 
			
		||||
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
 | 
			
		||||
import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util';
 | 
			
		||||
@ -125,6 +125,10 @@ export class AssetService extends BaseService {
 | 
			
		||||
      options.rating !== undefined
 | 
			
		||||
    ) {
 | 
			
		||||
      await this.assetRepository.updateAll(ids, options);
 | 
			
		||||
 | 
			
		||||
      if (options.visibility === AssetVisibility.LOCKED) {
 | 
			
		||||
        await this.albumRepository.removeAssetsFromAll(ids);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -253,6 +253,7 @@ describe(AuthService.name, () => {
 | 
			
		||||
        id: session.id,
 | 
			
		||||
        updatedAt: session.updatedAt,
 | 
			
		||||
        user: factory.authUser(),
 | 
			
		||||
        pinExpiresAt: null,
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      mocks.session.getByToken.mockResolvedValue(sessionWithToken);
 | 
			
		||||
@ -265,7 +266,7 @@ describe(AuthService.name, () => {
 | 
			
		||||
        }),
 | 
			
		||||
      ).resolves.toEqual({
 | 
			
		||||
        user: sessionWithToken.user,
 | 
			
		||||
        session: { id: session.id },
 | 
			
		||||
        session: { id: session.id, hasElevatedPermission: false },
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
@ -376,6 +377,7 @@ describe(AuthService.name, () => {
 | 
			
		||||
        id: session.id,
 | 
			
		||||
        updatedAt: session.updatedAt,
 | 
			
		||||
        user: factory.authUser(),
 | 
			
		||||
        pinExpiresAt: null,
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      mocks.session.getByToken.mockResolvedValue(sessionWithToken);
 | 
			
		||||
@ -388,7 +390,7 @@ describe(AuthService.name, () => {
 | 
			
		||||
        }),
 | 
			
		||||
      ).resolves.toEqual({
 | 
			
		||||
        user: sessionWithToken.user,
 | 
			
		||||
        session: { id: session.id },
 | 
			
		||||
        session: { id: session.id, hasElevatedPermission: false },
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -398,6 +400,7 @@ describe(AuthService.name, () => {
 | 
			
		||||
        id: session.id,
 | 
			
		||||
        updatedAt: session.updatedAt,
 | 
			
		||||
        user: factory.authUser(),
 | 
			
		||||
        pinExpiresAt: null,
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      mocks.session.getByToken.mockResolvedValue(sessionWithToken);
 | 
			
		||||
@ -417,6 +420,7 @@ describe(AuthService.name, () => {
 | 
			
		||||
        id: session.id,
 | 
			
		||||
        updatedAt: session.updatedAt,
 | 
			
		||||
        user: factory.authUser(),
 | 
			
		||||
        pinExpiresAt: null,
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      mocks.session.getByToken.mockResolvedValue(sessionWithToken);
 | 
			
		||||
@ -916,13 +920,17 @@ describe(AuthService.name, () => {
 | 
			
		||||
 | 
			
		||||
  describe('resetPinCode', () => {
 | 
			
		||||
    it('should reset the PIN code', async () => {
 | 
			
		||||
      const currentSession = factory.session();
 | 
			
		||||
      const user = factory.userAdmin();
 | 
			
		||||
      mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
 | 
			
		||||
      mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
 | 
			
		||||
      mocks.session.getByUserId.mockResolvedValue([currentSession]);
 | 
			
		||||
      mocks.session.update.mockResolvedValue(currentSession);
 | 
			
		||||
 | 
			
		||||
      await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' });
 | 
			
		||||
 | 
			
		||||
      expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null });
 | 
			
		||||
      expect(mocks.session.update).toHaveBeenCalledWith(currentSession.id, { pinExpiresAt: null });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should throw if the PIN code does not match', async () => {
 | 
			
		||||
 | 
			
		||||
@ -126,6 +126,10 @@ export class AuthService extends BaseService {
 | 
			
		||||
    this.resetPinChecks(user, dto);
 | 
			
		||||
 | 
			
		||||
    await this.userRepository.update(auth.user.id, { pinCode: null });
 | 
			
		||||
    const sessions = await this.sessionRepository.getByUserId(auth.user.id);
 | 
			
		||||
    for (const session of sessions) {
 | 
			
		||||
      await this.sessionRepository.update(session.id, { pinExpiresAt: null });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async changePinCode(auth: AuthDto, dto: PinCodeChangeDto) {
 | 
			
		||||
@ -444,10 +448,25 @@ export class AuthService extends BaseService {
 | 
			
		||||
        await this.sessionRepository.update(session.id, { id: session.id, updatedAt: new Date() });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Pin check
 | 
			
		||||
      let hasElevatedPermission = false;
 | 
			
		||||
 | 
			
		||||
      if (session.pinExpiresAt) {
 | 
			
		||||
        const pinExpiresAt = DateTime.fromJSDate(session.pinExpiresAt);
 | 
			
		||||
        hasElevatedPermission = pinExpiresAt > now;
 | 
			
		||||
 | 
			
		||||
        if (hasElevatedPermission && now.plus({ minutes: 5 }) > pinExpiresAt) {
 | 
			
		||||
          await this.sessionRepository.update(session.id, {
 | 
			
		||||
            pinExpiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate(),
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        user: session.user,
 | 
			
		||||
        session: {
 | 
			
		||||
          id: session.id,
 | 
			
		||||
          hasElevatedPermission,
 | 
			
		||||
        },
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
@ -455,6 +474,23 @@ export class AuthService extends BaseService {
 | 
			
		||||
    throw new UnauthorizedException('Invalid user token');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async verifyPinCode(auth: AuthDto, dto: PinCodeSetupDto): Promise<void> {
 | 
			
		||||
    const user = await this.userRepository.getForPinCode(auth.user.id);
 | 
			
		||||
    if (!user) {
 | 
			
		||||
      throw new UnauthorizedException();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.resetPinChecks(user, { pinCode: dto.pinCode });
 | 
			
		||||
 | 
			
		||||
    if (!auth.session) {
 | 
			
		||||
      throw new BadRequestException('Session is missing');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await this.sessionRepository.update(auth.session.id, {
 | 
			
		||||
      pinExpiresAt: new Date(DateTime.now().plus({ minutes: 15 }).toJSDate()),
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) {
 | 
			
		||||
    const key = this.cryptoRepository.newPassword(32);
 | 
			
		||||
    const token = this.cryptoRepository.hashSha256(key);
 | 
			
		||||
@ -493,6 +529,7 @@ export class AuthService extends BaseService {
 | 
			
		||||
    return {
 | 
			
		||||
      pinCode: !!user.pinCode,
 | 
			
		||||
      password: !!user.password,
 | 
			
		||||
      isElevated: !!auth.session?.hasElevatedPermission,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1310,7 +1310,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
      expect(mocks.asset.update).not.toHaveBeenCalledWith(
 | 
			
		||||
        expect.objectContaining({ visibility: AssetVisibility.HIDDEN }),
 | 
			
		||||
      );
 | 
			
		||||
      expect(mocks.album.removeAsset).not.toHaveBeenCalled();
 | 
			
		||||
      expect(mocks.album.removeAssetsFromAll).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should handle not finding a match', async () => {
 | 
			
		||||
@ -1331,7 +1331,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
      expect(mocks.asset.update).not.toHaveBeenCalledWith(
 | 
			
		||||
        expect.objectContaining({ visibility: AssetVisibility.HIDDEN }),
 | 
			
		||||
      );
 | 
			
		||||
      expect(mocks.album.removeAsset).not.toHaveBeenCalled();
 | 
			
		||||
      expect(mocks.album.removeAssetsFromAll).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should link photo and video', async () => {
 | 
			
		||||
@ -1356,7 +1356,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
        id: assetStub.livePhotoMotionAsset.id,
 | 
			
		||||
        visibility: AssetVisibility.HIDDEN,
 | 
			
		||||
      });
 | 
			
		||||
      expect(mocks.album.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id);
 | 
			
		||||
      expect(mocks.album.removeAssetsFromAll).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should notify clients on live photo link', async () => {
 | 
			
		||||
 | 
			
		||||
@ -158,7 +158,7 @@ export class MetadataService extends BaseService {
 | 
			
		||||
    await Promise.all([
 | 
			
		||||
      this.assetRepository.update({ id: photoAsset.id, livePhotoVideoId: motionAsset.id }),
 | 
			
		||||
      this.assetRepository.update({ id: motionAsset.id, visibility: AssetVisibility.HIDDEN }),
 | 
			
		||||
      this.albumRepository.removeAsset(motionAsset.id),
 | 
			
		||||
      this.albumRepository.removeAssetsFromAll([motionAsset.id]),
 | 
			
		||||
    ]);
 | 
			
		||||
 | 
			
		||||
    await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId });
 | 
			
		||||
 | 
			
		||||
@ -34,6 +34,7 @@ describe('SessionService', () => {
 | 
			
		||||
          token: '420',
 | 
			
		||||
          userId: '42',
 | 
			
		||||
          updateId: 'uuid-v7',
 | 
			
		||||
          pinExpiresAt: null,
 | 
			
		||||
        },
 | 
			
		||||
      ]);
 | 
			
		||||
      mocks.session.delete.mockResolvedValue();
 | 
			
		||||
 | 
			
		||||
@ -156,6 +156,7 @@ describe(SharedLinkService.name, () => {
 | 
			
		||||
      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
 | 
			
		||||
        authStub.admin.user.id,
 | 
			
		||||
        new Set([assetStub.image.id]),
 | 
			
		||||
        false,
 | 
			
		||||
      );
 | 
			
		||||
      expect(mocks.sharedLink.create).toHaveBeenCalledWith({
 | 
			
		||||
        type: SharedLinkType.INDIVIDUAL,
 | 
			
		||||
@ -186,6 +187,7 @@ describe(SharedLinkService.name, () => {
 | 
			
		||||
      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
 | 
			
		||||
        authStub.admin.user.id,
 | 
			
		||||
        new Set([assetStub.image.id]),
 | 
			
		||||
        false,
 | 
			
		||||
      );
 | 
			
		||||
      expect(mocks.sharedLink.create).toHaveBeenCalledWith({
 | 
			
		||||
        type: SharedLinkType.INDIVIDUAL,
 | 
			
		||||
 | 
			
		||||
@ -81,7 +81,7 @@ const checkSharedLinkAccess = async (
 | 
			
		||||
 | 
			
		||||
    case Permission.ASSET_SHARE: {
 | 
			
		||||
      // TODO: fix this to not use sharedLink.userId for access control
 | 
			
		||||
      return await access.asset.checkOwnerAccess(sharedLink.userId, ids);
 | 
			
		||||
      return await access.asset.checkOwnerAccess(sharedLink.userId, ids, false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    case Permission.ALBUM_READ: {
 | 
			
		||||
@ -119,38 +119,38 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    case Permission.ASSET_READ: {
 | 
			
		||||
      const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids);
 | 
			
		||||
      const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
 | 
			
		||||
      const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
 | 
			
		||||
      const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
 | 
			
		||||
      return setUnion(isOwner, isAlbum, isPartner);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    case Permission.ASSET_SHARE: {
 | 
			
		||||
      const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids);
 | 
			
		||||
      const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, false);
 | 
			
		||||
      const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
 | 
			
		||||
      return setUnion(isOwner, isPartner);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    case Permission.ASSET_VIEW: {
 | 
			
		||||
      const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids);
 | 
			
		||||
      const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
 | 
			
		||||
      const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
 | 
			
		||||
      const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
 | 
			
		||||
      return setUnion(isOwner, isAlbum, isPartner);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    case Permission.ASSET_DOWNLOAD: {
 | 
			
		||||
      const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids);
 | 
			
		||||
      const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
 | 
			
		||||
      const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
 | 
			
		||||
      const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
 | 
			
		||||
      return setUnion(isOwner, isAlbum, isPartner);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    case Permission.ASSET_UPDATE: {
 | 
			
		||||
      return await access.asset.checkOwnerAccess(auth.user.id, ids);
 | 
			
		||||
      return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    case Permission.ASSET_DELETE: {
 | 
			
		||||
      return await access.asset.checkOwnerAccess(auth.user.id, ids);
 | 
			
		||||
      return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    case Permission.ALBUM_READ: {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										6
									
								
								server/test/fixtures/auth.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								server/test/fixtures/auth.stub.ts
									
									
									
									
										vendored
									
									
								
							@ -1,4 +1,4 @@
 | 
			
		||||
import { Session } from 'src/database';
 | 
			
		||||
import { AuthSession } from 'src/database';
 | 
			
		||||
import { AuthDto } from 'src/dtos/auth.dto';
 | 
			
		||||
 | 
			
		||||
const authUser = {
 | 
			
		||||
@ -26,7 +26,7 @@ export const authStub = {
 | 
			
		||||
    user: authUser.user1,
 | 
			
		||||
    session: {
 | 
			
		||||
      id: 'token-id',
 | 
			
		||||
    } as Session,
 | 
			
		||||
    } as AuthSession,
 | 
			
		||||
  }),
 | 
			
		||||
  user2: Object.freeze<AuthDto>({
 | 
			
		||||
    user: {
 | 
			
		||||
@ -39,7 +39,7 @@ export const authStub = {
 | 
			
		||||
    },
 | 
			
		||||
    session: {
 | 
			
		||||
      id: 'token-id',
 | 
			
		||||
    } as Session,
 | 
			
		||||
    } as AuthSession,
 | 
			
		||||
  }),
 | 
			
		||||
  adminSharedLink: Object.freeze({
 | 
			
		||||
    user: authUser.admin,
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								server/test/fixtures/shared-link.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								server/test/fixtures/shared-link.stub.ts
									
									
									
									
										vendored
									
									
								
							@ -70,6 +70,7 @@ const assetResponse: AssetResponseDto = {
 | 
			
		||||
  isTrashed: false,
 | 
			
		||||
  libraryId: 'library-id',
 | 
			
		||||
  hasMetadata: true,
 | 
			
		||||
  visibility: AssetVisibility.TIMELINE,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const assetResponseWithoutMetadata = {
 | 
			
		||||
 | 
			
		||||
@ -58,7 +58,7 @@ const authFactory = ({
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (session) {
 | 
			
		||||
    auth.session = { id: session.id };
 | 
			
		||||
    auth.session = { id: session.id, hasElevatedPermission: false };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (sharedLink) {
 | 
			
		||||
@ -127,6 +127,7 @@ const sessionFactory = (session: Partial<Session> = {}) => ({
 | 
			
		||||
  deviceType: 'mobile',
 | 
			
		||||
  token: 'abc123',
 | 
			
		||||
  userId: newUuid(),
 | 
			
		||||
  pinExpiresAt: newDate(),
 | 
			
		||||
  ...session,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -13,6 +13,8 @@ type ActionMap = {
 | 
			
		||||
  [AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto };
 | 
			
		||||
  [AssetAction.UNSTACK]: { assets: AssetResponseDto[] };
 | 
			
		||||
  [AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: AssetResponseDto };
 | 
			
		||||
  [AssetAction.SET_VISIBILITY_LOCKED]: { asset: AssetResponseDto };
 | 
			
		||||
  [AssetAction.SET_VISIBILITY_TIMELINE]: { asset: AssetResponseDto };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type Action = {
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,60 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
 | 
			
		||||
 | 
			
		||||
  import { AssetAction } from '$lib/constants';
 | 
			
		||||
  import { modalManager } from '$lib/managers/modal-manager.svelte';
 | 
			
		||||
  import { handleError } from '$lib/utils/handle-error';
 | 
			
		||||
  import { AssetVisibility, updateAssets, Visibility, type AssetResponseDto } from '@immich/sdk';
 | 
			
		||||
  import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
  import type { OnAction, PreAction } from './action';
 | 
			
		||||
 | 
			
		||||
  interface Props {
 | 
			
		||||
    asset: AssetResponseDto;
 | 
			
		||||
    onAction: OnAction;
 | 
			
		||||
    preAction: PreAction;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let { asset, onAction, preAction }: Props = $props();
 | 
			
		||||
  const isLocked = asset.visibility === Visibility.Locked;
 | 
			
		||||
 | 
			
		||||
  const toggleLockedVisibility = async () => {
 | 
			
		||||
    const isConfirmed = await modalManager.showDialog({
 | 
			
		||||
      title: isLocked ? $t('remove_from_locked_folder') : $t('move_to_locked_folder'),
 | 
			
		||||
      prompt: isLocked ? $t('remove_from_locked_folder_confirmation') : $t('move_to_locked_folder_confirmation'),
 | 
			
		||||
      confirmText: $t('move'),
 | 
			
		||||
      confirmColor: isLocked ? 'danger' : 'primary',
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (!isConfirmed) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      preAction({
 | 
			
		||||
        type: isLocked ? AssetAction.SET_VISIBILITY_TIMELINE : AssetAction.SET_VISIBILITY_LOCKED,
 | 
			
		||||
        asset,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await updateAssets({
 | 
			
		||||
        assetBulkUpdateDto: {
 | 
			
		||||
          ids: [asset.id],
 | 
			
		||||
          visibility: isLocked ? AssetVisibility.Timeline : AssetVisibility.Locked,
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      onAction({
 | 
			
		||||
        type: isLocked ? AssetAction.SET_VISIBILITY_TIMELINE : AssetAction.SET_VISIBILITY_LOCKED,
 | 
			
		||||
        asset,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, $t('errors.unable_to_save_settings'));
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<MenuOption
 | 
			
		||||
  onClick={() => toggleLockedVisibility()}
 | 
			
		||||
  text={isLocked ? $t('move_off_locked_folder') : $t('add_to_locked_folder')}
 | 
			
		||||
  icon={isLocked ? mdiFolderMoveOutline : mdiEyeOffOutline}
 | 
			
		||||
/>
 | 
			
		||||
@ -12,6 +12,7 @@
 | 
			
		||||
  import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte';
 | 
			
		||||
  import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte';
 | 
			
		||||
  import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte';
 | 
			
		||||
  import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte';
 | 
			
		||||
  import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte';
 | 
			
		||||
  import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
 | 
			
		||||
  import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
 | 
			
		||||
@ -27,6 +28,7 @@
 | 
			
		||||
  import {
 | 
			
		||||
    AssetJobName,
 | 
			
		||||
    AssetTypeEnum,
 | 
			
		||||
    Visibility,
 | 
			
		||||
    type AlbumResponseDto,
 | 
			
		||||
    type AssetResponseDto,
 | 
			
		||||
    type PersonResponseDto,
 | 
			
		||||
@ -91,6 +93,7 @@
 | 
			
		||||
  const sharedLink = getSharedLink();
 | 
			
		||||
  let isOwner = $derived($user && asset.ownerId === $user?.id);
 | 
			
		||||
  let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline);
 | 
			
		||||
  let isLocked = $derived(asset.visibility === Visibility.Locked);
 | 
			
		||||
 | 
			
		||||
  // $: showEditorButton =
 | 
			
		||||
  //   isOwner &&
 | 
			
		||||
@ -112,7 +115,7 @@
 | 
			
		||||
    {/if}
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="flex gap-2 overflow-x-auto text-white" data-testid="asset-viewer-navbar-actions">
 | 
			
		||||
    {#if !asset.isTrashed && $user}
 | 
			
		||||
    {#if !asset.isTrashed && $user && !isLocked}
 | 
			
		||||
      <ShareAction {asset} />
 | 
			
		||||
    {/if}
 | 
			
		||||
    {#if asset.isOffline}
 | 
			
		||||
@ -159,17 +162,20 @@
 | 
			
		||||
      <DeleteAction {asset} {onAction} {preAction} />
 | 
			
		||||
 | 
			
		||||
      <ButtonContextMenu direction="left" align="top-right" color="opaque" title={$t('more')} icon={mdiDotsVertical}>
 | 
			
		||||
        {#if showSlideshow}
 | 
			
		||||
        {#if showSlideshow && !isLocked}
 | 
			
		||||
          <MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
 | 
			
		||||
        {/if}
 | 
			
		||||
        {#if showDownloadButton}
 | 
			
		||||
          <DownloadAction {asset} menuItem />
 | 
			
		||||
        {/if}
 | 
			
		||||
        {#if asset.isTrashed}
 | 
			
		||||
          <RestoreAction {asset} {onAction} />
 | 
			
		||||
        {:else}
 | 
			
		||||
          <AddToAlbumAction {asset} {onAction} />
 | 
			
		||||
          <AddToAlbumAction {asset} {onAction} shared />
 | 
			
		||||
 | 
			
		||||
        {#if !isLocked}
 | 
			
		||||
          {#if asset.isTrashed}
 | 
			
		||||
            <RestoreAction {asset} {onAction} />
 | 
			
		||||
          {:else}
 | 
			
		||||
            <AddToAlbumAction {asset} {onAction} />
 | 
			
		||||
            <AddToAlbumAction {asset} {onAction} shared />
 | 
			
		||||
          {/if}
 | 
			
		||||
        {/if}
 | 
			
		||||
 | 
			
		||||
        {#if isOwner}
 | 
			
		||||
@ -183,21 +189,28 @@
 | 
			
		||||
          {#if person}
 | 
			
		||||
            <SetFeaturedPhotoAction {asset} {person} />
 | 
			
		||||
          {/if}
 | 
			
		||||
          {#if asset.type === AssetTypeEnum.Image}
 | 
			
		||||
          {#if asset.type === AssetTypeEnum.Image && !isLocked}
 | 
			
		||||
            <SetProfilePictureAction {asset} />
 | 
			
		||||
          {/if}
 | 
			
		||||
          <ArchiveAction {asset} {onAction} {preAction} />
 | 
			
		||||
          <MenuOption
 | 
			
		||||
            icon={mdiUpload}
 | 
			
		||||
            onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
 | 
			
		||||
            text={$t('replace_with_upload')}
 | 
			
		||||
          />
 | 
			
		||||
          {#if !asset.isArchived && !asset.isTrashed}
 | 
			
		||||
 | 
			
		||||
          {#if !isLocked}
 | 
			
		||||
            <ArchiveAction {asset} {onAction} {preAction} />
 | 
			
		||||
            <MenuOption
 | 
			
		||||
              icon={mdiImageSearch}
 | 
			
		||||
              onClick={() => goto(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`)}
 | 
			
		||||
              text={$t('view_in_timeline')}
 | 
			
		||||
              icon={mdiUpload}
 | 
			
		||||
              onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
 | 
			
		||||
              text={$t('replace_with_upload')}
 | 
			
		||||
            />
 | 
			
		||||
            {#if !asset.isArchived && !asset.isTrashed}
 | 
			
		||||
              <MenuOption
 | 
			
		||||
                icon={mdiImageSearch}
 | 
			
		||||
                onClick={() => goto(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`)}
 | 
			
		||||
                text={$t('view_in_timeline')}
 | 
			
		||||
              />
 | 
			
		||||
            {/if}
 | 
			
		||||
          {/if}
 | 
			
		||||
 | 
			
		||||
          {#if !asset.isTrashed}
 | 
			
		||||
            <SetVisibilityAction {asset} {onAction} {preAction} />
 | 
			
		||||
          {/if}
 | 
			
		||||
          <hr />
 | 
			
		||||
          <MenuOption
 | 
			
		||||
 | 
			
		||||
@ -2,11 +2,12 @@
 | 
			
		||||
  import { Card, CardBody, CardHeader, Heading, immichLogo, Logo, VStack } from '@immich/ui';
 | 
			
		||||
  import type { Snippet } from 'svelte';
 | 
			
		||||
  interface Props {
 | 
			
		||||
    title: string;
 | 
			
		||||
    title?: string;
 | 
			
		||||
    children?: Snippet;
 | 
			
		||||
    withHeader?: boolean;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let { title, children }: Props = $props();
 | 
			
		||||
  let { title, children, withHeader = true }: Props = $props();
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<section class="min-w-dvw flex min-h-dvh items-center justify-center relative">
 | 
			
		||||
@ -18,12 +19,14 @@
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <Card color="secondary" class="w-full max-w-lg border m-2">
 | 
			
		||||
    <CardHeader class="mt-6">
 | 
			
		||||
      <VStack>
 | 
			
		||||
        <Logo variant="icon" size="giant" />
 | 
			
		||||
        <Heading size="large" class="font-semibold" color="primary" tag="h1">{title}</Heading>
 | 
			
		||||
      </VStack>
 | 
			
		||||
    </CardHeader>
 | 
			
		||||
    {#if withHeader}
 | 
			
		||||
      <CardHeader class="mt-6">
 | 
			
		||||
        <VStack>
 | 
			
		||||
          <Logo variant="icon" size="giant" />
 | 
			
		||||
          <Heading size="large" class="font-semibold" color="primary" tag="h1">{title}</Heading>
 | 
			
		||||
        </VStack>
 | 
			
		||||
      </CardHeader>
 | 
			
		||||
    {/if}
 | 
			
		||||
 | 
			
		||||
    <CardBody class="p-8">
 | 
			
		||||
      {@render children?.()}
 | 
			
		||||
 | 
			
		||||
@ -1,12 +1,12 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
 | 
			
		||||
  import { featureFlags } from '$lib/stores/server-config.store';
 | 
			
		||||
  import { type OnDelete, deleteAssets } from '$lib/utils/actions';
 | 
			
		||||
  import { mdiDeleteForeverOutline, mdiDeleteOutline, mdiTimerSand } from '@mdi/js';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
  import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
 | 
			
		||||
  import { getAssetControlContext } from '../asset-select-control-bar.svelte';
 | 
			
		||||
  import { featureFlags } from '$lib/stores/server-config.store';
 | 
			
		||||
  import { mdiTimerSand, mdiDeleteOutline, mdiDeleteForeverOutline } from '@mdi/js';
 | 
			
		||||
  import { type OnDelete, deleteAssets } from '$lib/utils/actions';
 | 
			
		||||
  import DeleteAssetDialog from '../delete-asset-dialog.svelte';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
 | 
			
		||||
  interface Props {
 | 
			
		||||
    onAssetDelete: OnDelete;
 | 
			
		||||
 | 
			
		||||
@ -1,17 +1,19 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
 | 
			
		||||
  import { type AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
 | 
			
		||||
  import { mdiSelectAll, mdiSelectRemove } from '@mdi/js';
 | 
			
		||||
  import { selectAllAssets, cancelMultiselect } from '$lib/utils/asset-utils';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
  import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
 | 
			
		||||
  import { type AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
 | 
			
		||||
  import { cancelMultiselect, selectAllAssets } from '$lib/utils/asset-utils';
 | 
			
		||||
  import { Button } from '@immich/ui';
 | 
			
		||||
  import { mdiSelectAll, mdiSelectRemove } from '@mdi/js';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
 | 
			
		||||
  interface Props {
 | 
			
		||||
    assetStore: AssetStore;
 | 
			
		||||
    assetInteraction: AssetInteraction;
 | 
			
		||||
    withText?: boolean;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let { assetStore, assetInteraction }: Props = $props();
 | 
			
		||||
  let { assetStore, assetInteraction, withText = false }: Props = $props();
 | 
			
		||||
 | 
			
		||||
  const handleSelectAll = async () => {
 | 
			
		||||
    await selectAllAssets(assetStore, assetInteraction);
 | 
			
		||||
@ -22,8 +24,20 @@
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
{#if $isSelectingAllAssets}
 | 
			
		||||
  <CircleIconButton title={$t('unselect_all')} icon={mdiSelectRemove} onclick={handleCancel} />
 | 
			
		||||
{#if withText}
 | 
			
		||||
  <Button
 | 
			
		||||
    leadingIcon={$isSelectingAllAssets ? mdiSelectRemove : mdiSelectAll}
 | 
			
		||||
    size="medium"
 | 
			
		||||
    color="secondary"
 | 
			
		||||
    variant="ghost"
 | 
			
		||||
    onclick={$isSelectingAllAssets ? handleCancel : handleSelectAll}
 | 
			
		||||
  >
 | 
			
		||||
    {$isSelectingAllAssets ? $t('unselect_all') : $t('select_all')}
 | 
			
		||||
  </Button>
 | 
			
		||||
{:else}
 | 
			
		||||
  <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAll} />
 | 
			
		||||
  <CircleIconButton
 | 
			
		||||
    title={$isSelectingAllAssets ? $t('unselect_all') : $t('select_all')}
 | 
			
		||||
    icon={$isSelectingAllAssets ? mdiSelectRemove : mdiSelectAll}
 | 
			
		||||
    onclick={$isSelectingAllAssets ? handleCancel : handleSelectAll}
 | 
			
		||||
  />
 | 
			
		||||
{/if}
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,72 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte';
 | 
			
		||||
  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
 | 
			
		||||
  import { modalManager } from '$lib/managers/modal-manager.svelte';
 | 
			
		||||
 | 
			
		||||
  import type { OnSetVisibility } from '$lib/utils/actions';
 | 
			
		||||
  import { handleError } from '$lib/utils/handle-error';
 | 
			
		||||
  import { AssetVisibility, updateAssets } from '@immich/sdk';
 | 
			
		||||
  import { Button } from '@immich/ui';
 | 
			
		||||
  import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
 | 
			
		||||
  interface Props {
 | 
			
		||||
    onVisibilitySet: OnSetVisibility;
 | 
			
		||||
    menuItem?: boolean;
 | 
			
		||||
    unlock?: boolean;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let { onVisibilitySet, menuItem = false, unlock = false }: Props = $props();
 | 
			
		||||
  let loading = $state(false);
 | 
			
		||||
  const { getAssets } = getAssetControlContext();
 | 
			
		||||
 | 
			
		||||
  const setLockedVisibility = async () => {
 | 
			
		||||
    const isConfirmed = await modalManager.showDialog({
 | 
			
		||||
      title: unlock ? $t('remove_from_locked_folder') : $t('move_to_locked_folder'),
 | 
			
		||||
      prompt: unlock ? $t('remove_from_locked_folder_confirmation') : $t('move_to_locked_folder_confirmation'),
 | 
			
		||||
      confirmText: $t('move'),
 | 
			
		||||
      confirmColor: unlock ? 'danger' : 'primary',
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (!isConfirmed) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      loading = true;
 | 
			
		||||
      const assetIds = getAssets().map(({ id }) => id);
 | 
			
		||||
 | 
			
		||||
      await updateAssets({
 | 
			
		||||
        assetBulkUpdateDto: {
 | 
			
		||||
          ids: assetIds,
 | 
			
		||||
          visibility: unlock ? AssetVisibility.Timeline : AssetVisibility.Locked,
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      onVisibilitySet(assetIds);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, $t('errors.unable_to_save_settings'));
 | 
			
		||||
    } finally {
 | 
			
		||||
      loading = false;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
{#if menuItem}
 | 
			
		||||
  <MenuOption
 | 
			
		||||
    onClick={setLockedVisibility}
 | 
			
		||||
    text={unlock ? $t('move_off_locked_folder') : $t('add_to_locked_folder')}
 | 
			
		||||
    icon={unlock ? mdiFolderMoveOutline : mdiEyeOffOutline}
 | 
			
		||||
  />
 | 
			
		||||
{:else}
 | 
			
		||||
  <Button
 | 
			
		||||
    leadingIcon={unlock ? mdiFolderMoveOutline : mdiEyeOffOutline}
 | 
			
		||||
    disabled={loading}
 | 
			
		||||
    size="medium"
 | 
			
		||||
    color="secondary"
 | 
			
		||||
    variant="ghost"
 | 
			
		||||
    onclick={setLockedVisibility}
 | 
			
		||||
  >
 | 
			
		||||
    {unlock ? $t('move_off_locked_folder') : $t('add_to_locked_folder')}
 | 
			
		||||
  </Button>
 | 
			
		||||
{/if}
 | 
			
		||||
@ -39,7 +39,13 @@
 | 
			
		||||
    enableRouting: boolean;
 | 
			
		||||
    assetStore: AssetStore;
 | 
			
		||||
    assetInteraction: AssetInteraction;
 | 
			
		||||
    removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.FAVORITE | AssetAction.UNFAVORITE | null;
 | 
			
		||||
    removeAction?:
 | 
			
		||||
      | AssetAction.UNARCHIVE
 | 
			
		||||
      | AssetAction.ARCHIVE
 | 
			
		||||
      | AssetAction.FAVORITE
 | 
			
		||||
      | AssetAction.UNFAVORITE
 | 
			
		||||
      | AssetAction.SET_VISIBILITY_TIMELINE
 | 
			
		||||
      | null;
 | 
			
		||||
    withStacked?: boolean;
 | 
			
		||||
    showArchiveIcon?: boolean;
 | 
			
		||||
    isShared?: boolean;
 | 
			
		||||
@ -417,7 +423,9 @@
 | 
			
		||||
      case AssetAction.TRASH:
 | 
			
		||||
      case AssetAction.RESTORE:
 | 
			
		||||
      case AssetAction.DELETE:
 | 
			
		||||
      case AssetAction.ARCHIVE: {
 | 
			
		||||
      case AssetAction.ARCHIVE:
 | 
			
		||||
      case AssetAction.SET_VISIBILITY_LOCKED:
 | 
			
		||||
      case AssetAction.SET_VISIBILITY_TIMELINE: {
 | 
			
		||||
        // find the next asset to show or close the viewer
 | 
			
		||||
        // eslint-disable-next-line @typescript-eslint/no-unused-expressions
 | 
			
		||||
        (await handleNext()) || (await handlePrevious()) || (await handleClose({ asset: action.asset }));
 | 
			
		||||
@ -445,6 +453,7 @@
 | 
			
		||||
 | 
			
		||||
      case AssetAction.UNSTACK: {
 | 
			
		||||
        updateUnstackedAssetInTimeline(assetStore, action.assets);
 | 
			
		||||
        break;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
@ -6,9 +6,10 @@
 | 
			
		||||
    text: string;
 | 
			
		||||
    fullWidth?: boolean;
 | 
			
		||||
    src?: string;
 | 
			
		||||
    title?: string;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let { onClick = undefined, text, fullWidth = false, src = empty1Url }: Props = $props();
 | 
			
		||||
  let { onClick = undefined, text, fullWidth = false, src = empty1Url, title }: Props = $props();
 | 
			
		||||
 | 
			
		||||
  let width = $derived(fullWidth ? 'w-full' : 'w-1/2');
 | 
			
		||||
 | 
			
		||||
@ -24,5 +25,9 @@
 | 
			
		||||
  class="{width} m-auto mt-10 flex flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray {hoverClasses}"
 | 
			
		||||
>
 | 
			
		||||
  <img {src} alt="" width="500" draggable="false" />
 | 
			
		||||
  <p class="text-immich-text-gray-500 dark:text-immich-dark-fg">{text}</p>
 | 
			
		||||
 | 
			
		||||
  {#if title}
 | 
			
		||||
    <h2 class="text-xl font-medium my-4">{title}</h2>
 | 
			
		||||
  {/if}
 | 
			
		||||
  <p class="text-immich-text-gray-500 dark:text-immich-dark-fg font-light">{text}</p>
 | 
			
		||||
</svelte:element>
 | 
			
		||||
 | 
			
		||||
@ -19,6 +19,8 @@
 | 
			
		||||
    mdiImageMultiple,
 | 
			
		||||
    mdiImageMultipleOutline,
 | 
			
		||||
    mdiLink,
 | 
			
		||||
    mdiLock,
 | 
			
		||||
    mdiLockOutline,
 | 
			
		||||
    mdiMagnify,
 | 
			
		||||
    mdiMap,
 | 
			
		||||
    mdiMapOutline,
 | 
			
		||||
@ -40,6 +42,7 @@
 | 
			
		||||
  let isSharingSelected: boolean = $state(false);
 | 
			
		||||
  let isTrashSelected: boolean = $state(false);
 | 
			
		||||
  let isUtilitiesSelected: boolean = $state(false);
 | 
			
		||||
  let isLockedFolderSelected: boolean = $state(false);
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<Sidebar ariaLabel={$t('primary')}>
 | 
			
		||||
@ -128,6 +131,13 @@
 | 
			
		||||
    icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
 | 
			
		||||
  ></SideBarLink>
 | 
			
		||||
 | 
			
		||||
  <SideBarLink
 | 
			
		||||
    title={$t('locked_folder')}
 | 
			
		||||
    routeId="/(user)/locked"
 | 
			
		||||
    bind:isSelected={isLockedFolderSelected}
 | 
			
		||||
    icon={isLockedFolderSelected ? mdiLock : mdiLockOutline}
 | 
			
		||||
  ></SideBarLink>
 | 
			
		||||
 | 
			
		||||
  {#if $featureFlags.trash}
 | 
			
		||||
    <SideBarLink
 | 
			
		||||
      title={$t('trash')}
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,79 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import {
 | 
			
		||||
    notificationController,
 | 
			
		||||
    NotificationType,
 | 
			
		||||
  } from '$lib/components/shared-components/notification/notification';
 | 
			
		||||
  import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
 | 
			
		||||
  import { handleError } from '$lib/utils/handle-error';
 | 
			
		||||
  import { changePinCode } from '@immich/sdk';
 | 
			
		||||
  import { Button } from '@immich/ui';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
  import { fade } from 'svelte/transition';
 | 
			
		||||
 | 
			
		||||
  let currentPinCode = $state('');
 | 
			
		||||
  let newPinCode = $state('');
 | 
			
		||||
  let confirmPinCode = $state('');
 | 
			
		||||
  let isLoading = $state(false);
 | 
			
		||||
  let canSubmit = $derived(currentPinCode.length === 6 && confirmPinCode.length === 6 && newPinCode === confirmPinCode);
 | 
			
		||||
 | 
			
		||||
  interface Props {
 | 
			
		||||
    onChanged?: () => void;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let { onChanged }: Props = $props();
 | 
			
		||||
 | 
			
		||||
  const handleSubmit = async (event: Event) => {
 | 
			
		||||
    event.preventDefault();
 | 
			
		||||
    await handleChangePinCode();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleChangePinCode = async () => {
 | 
			
		||||
    isLoading = true;
 | 
			
		||||
    try {
 | 
			
		||||
      await changePinCode({ pinCodeChangeDto: { pinCode: currentPinCode, newPinCode } });
 | 
			
		||||
 | 
			
		||||
      resetForm();
 | 
			
		||||
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: $t('pin_code_changed_successfully'),
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      onChanged?.();
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, $t('unable_to_change_pin_code'));
 | 
			
		||||
    } finally {
 | 
			
		||||
      isLoading = false;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const resetForm = () => {
 | 
			
		||||
    currentPinCode = '';
 | 
			
		||||
    newPinCode = '';
 | 
			
		||||
    confirmPinCode = '';
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<section class="my-4">
 | 
			
		||||
  <div in:fade={{ duration: 200 }}>
 | 
			
		||||
    <form autocomplete="off" onsubmit={handleSubmit} class="mt-6">
 | 
			
		||||
      <div class="flex flex-col gap-6 place-items-center place-content-center">
 | 
			
		||||
        <p class="text-dark">{$t('change_pin_code')}</p>
 | 
			
		||||
        <PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
 | 
			
		||||
 | 
			
		||||
        <PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
 | 
			
		||||
 | 
			
		||||
        <PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={13} pinLength={6} />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div class="flex justify-end gap-2 mt-4">
 | 
			
		||||
        <Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
 | 
			
		||||
          {$t('clear')}
 | 
			
		||||
        </Button>
 | 
			
		||||
        <Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
 | 
			
		||||
          {$t('save')}
 | 
			
		||||
        </Button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </form>
 | 
			
		||||
  </div>
 | 
			
		||||
</section>
 | 
			
		||||
@ -0,0 +1,72 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import {
 | 
			
		||||
    notificationController,
 | 
			
		||||
    NotificationType,
 | 
			
		||||
  } from '$lib/components/shared-components/notification/notification';
 | 
			
		||||
  import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
 | 
			
		||||
  import { handleError } from '$lib/utils/handle-error';
 | 
			
		||||
  import { setupPinCode } from '@immich/sdk';
 | 
			
		||||
  import { Button } from '@immich/ui';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
 | 
			
		||||
  interface Props {
 | 
			
		||||
    onCreated?: (pinCode: string) => void;
 | 
			
		||||
    showLabel?: boolean;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let { onCreated, showLabel = true }: Props = $props();
 | 
			
		||||
 | 
			
		||||
  let newPinCode = $state('');
 | 
			
		||||
  let confirmPinCode = $state('');
 | 
			
		||||
  let isLoading = $state(false);
 | 
			
		||||
  let canSubmit = $derived(confirmPinCode.length === 6 && newPinCode === confirmPinCode);
 | 
			
		||||
 | 
			
		||||
  const handleSubmit = async (event: Event) => {
 | 
			
		||||
    event.preventDefault();
 | 
			
		||||
    await createPinCode();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const createPinCode = async () => {
 | 
			
		||||
    isLoading = true;
 | 
			
		||||
    try {
 | 
			
		||||
      await setupPinCode({ pinCodeSetupDto: { pinCode: newPinCode } });
 | 
			
		||||
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: $t('pin_code_setup_successfully'),
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      onCreated?.(newPinCode);
 | 
			
		||||
      resetForm();
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, $t('unable_to_setup_pin_code'));
 | 
			
		||||
    } finally {
 | 
			
		||||
      isLoading = false;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const resetForm = () => {
 | 
			
		||||
    newPinCode = '';
 | 
			
		||||
    confirmPinCode = '';
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<form autocomplete="off" onsubmit={handleSubmit}>
 | 
			
		||||
  <div class="flex flex-col gap-6 place-items-center place-content-center">
 | 
			
		||||
    {#if showLabel}
 | 
			
		||||
      <p class="text-dark">{$t('setup_pin_code')}</p>
 | 
			
		||||
    {/if}
 | 
			
		||||
    <PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={1} pinLength={6} />
 | 
			
		||||
 | 
			
		||||
    <PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={7} pinLength={6} />
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="flex justify-end gap-2 mt-4">
 | 
			
		||||
    <Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
 | 
			
		||||
      {$t('clear')}
 | 
			
		||||
    </Button>
 | 
			
		||||
    <Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
 | 
			
		||||
      {$t('create')}
 | 
			
		||||
    </Button>
 | 
			
		||||
  </div>
 | 
			
		||||
</form>
 | 
			
		||||
@ -1,12 +1,25 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import { onMount } from 'svelte';
 | 
			
		||||
 | 
			
		||||
  interface Props {
 | 
			
		||||
    label: string;
 | 
			
		||||
    value?: string;
 | 
			
		||||
    pinLength?: number;
 | 
			
		||||
    tabindexStart?: number;
 | 
			
		||||
    autofocus?: boolean;
 | 
			
		||||
    onFilled?: (value: string) => void;
 | 
			
		||||
    type?: 'text' | 'password';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let { label, value = $bindable(''), pinLength = 6, tabindexStart = 0 }: Props = $props();
 | 
			
		||||
  let {
 | 
			
		||||
    label,
 | 
			
		||||
    value = $bindable(''),
 | 
			
		||||
    pinLength = 6,
 | 
			
		||||
    tabindexStart = 0,
 | 
			
		||||
    autofocus = false,
 | 
			
		||||
    onFilled,
 | 
			
		||||
    type = 'text',
 | 
			
		||||
  }: Props = $props();
 | 
			
		||||
 | 
			
		||||
  let pinValues = $state(Array.from({ length: pinLength }).fill(''));
 | 
			
		||||
  let pinCodeInputElements: HTMLInputElement[] = $state([]);
 | 
			
		||||
@ -17,6 +30,12 @@
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  onMount(() => {
 | 
			
		||||
    if (autofocus) {
 | 
			
		||||
      pinCodeInputElements[0]?.focus();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const focusNext = (index: number) => {
 | 
			
		||||
    pinCodeInputElements[Math.min(index + 1, pinLength - 1)]?.focus();
 | 
			
		||||
  };
 | 
			
		||||
@ -48,6 +67,10 @@
 | 
			
		||||
    if (value && index < pinLength - 1) {
 | 
			
		||||
      focusNext(index);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (value.length === pinLength) {
 | 
			
		||||
      onFilled?.(value);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  function handleKeydown(event: KeyboardEvent & { currentTarget: EventTarget & HTMLInputElement }) {
 | 
			
		||||
@ -97,13 +120,13 @@
 | 
			
		||||
    {#each { length: pinLength } as _, index (index)}
 | 
			
		||||
      <input
 | 
			
		||||
        tabindex={tabindexStart + index}
 | 
			
		||||
        type="text"
 | 
			
		||||
        {type}
 | 
			
		||||
        inputmode="numeric"
 | 
			
		||||
        pattern="[0-9]*"
 | 
			
		||||
        maxlength="1"
 | 
			
		||||
        bind:this={pinCodeInputElements[index]}
 | 
			
		||||
        id="pin-code-{index}"
 | 
			
		||||
        class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 bg-transparent text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono"
 | 
			
		||||
        class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 bg-transparent text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono bg-white dark:bg-light"
 | 
			
		||||
        bind:value={pinValues[index]}
 | 
			
		||||
        onkeydown={handleKeydown}
 | 
			
		||||
        oninput={(event) => handleInput(event, index)}
 | 
			
		||||
 | 
			
		||||
@ -1,116 +1,26 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import {
 | 
			
		||||
    notificationController,
 | 
			
		||||
    NotificationType,
 | 
			
		||||
  } from '$lib/components/shared-components/notification/notification';
 | 
			
		||||
  import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
 | 
			
		||||
  import { handleError } from '$lib/utils/handle-error';
 | 
			
		||||
  import { changePinCode, getAuthStatus, setupPinCode } from '@immich/sdk';
 | 
			
		||||
  import { Button } from '@immich/ui';
 | 
			
		||||
  import PinCodeChangeForm from '$lib/components/user-settings-page/PinCodeChangeForm.svelte';
 | 
			
		||||
  import PinCodeCreateForm from '$lib/components/user-settings-page/PinCodeCreateForm.svelte';
 | 
			
		||||
  import { getAuthStatus } from '@immich/sdk';
 | 
			
		||||
  import { onMount } from 'svelte';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
  import { fade } from 'svelte/transition';
 | 
			
		||||
 | 
			
		||||
  let hasPinCode = $state(false);
 | 
			
		||||
  let currentPinCode = $state('');
 | 
			
		||||
  let newPinCode = $state('');
 | 
			
		||||
  let confirmPinCode = $state('');
 | 
			
		||||
  let isLoading = $state(false);
 | 
			
		||||
  let canSubmit = $derived(
 | 
			
		||||
    (hasPinCode ? currentPinCode.length === 6 : true) && confirmPinCode.length === 6 && newPinCode === confirmPinCode,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  onMount(async () => {
 | 
			
		||||
    const authStatus = await getAuthStatus();
 | 
			
		||||
    hasPinCode = authStatus.pinCode;
 | 
			
		||||
    const { pinCode } = await getAuthStatus();
 | 
			
		||||
    hasPinCode = pinCode;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const handleSubmit = async (event: Event) => {
 | 
			
		||||
    event.preventDefault();
 | 
			
		||||
    await (hasPinCode ? handleChange() : handleSetup());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSetup = async () => {
 | 
			
		||||
    isLoading = true;
 | 
			
		||||
    try {
 | 
			
		||||
      await setupPinCode({ pinCodeSetupDto: { pinCode: newPinCode } });
 | 
			
		||||
 | 
			
		||||
      resetForm();
 | 
			
		||||
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: $t('pin_code_setup_successfully'),
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, $t('unable_to_setup_pin_code'));
 | 
			
		||||
    } finally {
 | 
			
		||||
      isLoading = false;
 | 
			
		||||
      hasPinCode = true;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleChange = async () => {
 | 
			
		||||
    isLoading = true;
 | 
			
		||||
    try {
 | 
			
		||||
      await changePinCode({ pinCodeChangeDto: { pinCode: currentPinCode, newPinCode } });
 | 
			
		||||
 | 
			
		||||
      resetForm();
 | 
			
		||||
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: $t('pin_code_changed_successfully'),
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, $t('unable_to_change_pin_code'));
 | 
			
		||||
    } finally {
 | 
			
		||||
      isLoading = false;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const resetForm = () => {
 | 
			
		||||
    currentPinCode = '';
 | 
			
		||||
    newPinCode = '';
 | 
			
		||||
    confirmPinCode = '';
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<section class="my-4">
 | 
			
		||||
  <div in:fade={{ duration: 200 }}>
 | 
			
		||||
    <form autocomplete="off" onsubmit={handleSubmit} class="mt-6">
 | 
			
		||||
      <div class="flex flex-col gap-6 place-items-center place-content-center">
 | 
			
		||||
        {#if hasPinCode}
 | 
			
		||||
          <p class="text-dark">{$t('change_pin_code')}</p>
 | 
			
		||||
          <PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
 | 
			
		||||
 | 
			
		||||
          <PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
 | 
			
		||||
 | 
			
		||||
          <PinCodeInput
 | 
			
		||||
            label={$t('confirm_new_pin_code')}
 | 
			
		||||
            bind:value={confirmPinCode}
 | 
			
		||||
            tabindexStart={13}
 | 
			
		||||
            pinLength={6}
 | 
			
		||||
          />
 | 
			
		||||
        {:else}
 | 
			
		||||
          <p class="text-dark">{$t('setup_pin_code')}</p>
 | 
			
		||||
          <PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={1} pinLength={6} />
 | 
			
		||||
 | 
			
		||||
          <PinCodeInput
 | 
			
		||||
            label={$t('confirm_new_pin_code')}
 | 
			
		||||
            bind:value={confirmPinCode}
 | 
			
		||||
            tabindexStart={7}
 | 
			
		||||
            pinLength={6}
 | 
			
		||||
          />
 | 
			
		||||
        {/if}
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div class="flex justify-end gap-2 mt-4">
 | 
			
		||||
        <Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
 | 
			
		||||
          {$t('clear')}
 | 
			
		||||
        </Button>
 | 
			
		||||
        <Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
 | 
			
		||||
          {hasPinCode ? $t('save') : $t('create')}
 | 
			
		||||
        </Button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </form>
 | 
			
		||||
  </div>
 | 
			
		||||
  {#if hasPinCode}
 | 
			
		||||
    <div in:fade={{ duration: 200 }} class="mt-6">
 | 
			
		||||
      <PinCodeChangeForm />
 | 
			
		||||
    </div>
 | 
			
		||||
  {:else}
 | 
			
		||||
    <div in:fade={{ duration: 200 }} class="mt-6">
 | 
			
		||||
      <PinCodeCreateForm onCreated={() => (hasPinCode = true)} />
 | 
			
		||||
    </div>
 | 
			
		||||
  {/if}
 | 
			
		||||
</section>
 | 
			
		||||
 | 
			
		||||
@ -10,6 +10,8 @@ export enum AssetAction {
 | 
			
		||||
  ADD_TO_ALBUM = 'add-to-album',
 | 
			
		||||
  UNSTACK = 'unstack',
 | 
			
		||||
  KEEP_THIS_DELETE_OTHERS = 'keep-this-delete-others',
 | 
			
		||||
  SET_VISIBILITY_LOCKED = 'set-visibility-locked',
 | 
			
		||||
  SET_VISIBILITY_TIMELINE = 'set-visibility-timeline',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export enum AppRoute {
 | 
			
		||||
@ -43,12 +45,14 @@ export enum AppRoute {
 | 
			
		||||
  AUTH_REGISTER = '/auth/register',
 | 
			
		||||
  AUTH_CHANGE_PASSWORD = '/auth/change-password',
 | 
			
		||||
  AUTH_ONBOARDING = '/auth/onboarding',
 | 
			
		||||
  AUTH_PIN_PROMPT = '/auth/pin-prompt',
 | 
			
		||||
 | 
			
		||||
  UTILITIES = '/utilities',
 | 
			
		||||
  DUPLICATES = '/utilities/duplicates',
 | 
			
		||||
 | 
			
		||||
  FOLDERS = '/folders',
 | 
			
		||||
  TAGS = '/tags',
 | 
			
		||||
  LOCKED = '/locked',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export enum ProjectionType {
 | 
			
		||||
 | 
			
		||||
@ -15,6 +15,7 @@ export type OnArchive = (ids: string[], isArchived: boolean) => void;
 | 
			
		||||
export type OnFavorite = (ids: string[], favorite: boolean) => void;
 | 
			
		||||
export type OnStack = (result: StackResponse) => void;
 | 
			
		||||
export type OnUnstack = (assets: AssetResponseDto[]) => void;
 | 
			
		||||
export type OnSetVisibility = (ids: string[]) => void;
 | 
			
		||||
 | 
			
		||||
export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => {
 | 
			
		||||
  const $t = get(t);
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,76 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
 | 
			
		||||
  import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
 | 
			
		||||
  import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
 | 
			
		||||
  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
 | 
			
		||||
  import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
 | 
			
		||||
  import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
 | 
			
		||||
  import SetVisibilityAction from '$lib/components/photos-page/actions/set-visibility-action.svelte';
 | 
			
		||||
  import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
 | 
			
		||||
  import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
 | 
			
		||||
  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
 | 
			
		||||
  import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
 | 
			
		||||
  import { AssetAction } from '$lib/constants';
 | 
			
		||||
  import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
 | 
			
		||||
  import { AssetStore } from '$lib/stores/assets-store.svelte';
 | 
			
		||||
  import { AssetVisibility } from '@immich/sdk';
 | 
			
		||||
  import { mdiDotsVertical } from '@mdi/js';
 | 
			
		||||
  import { onDestroy } from 'svelte';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
  import type { PageData } from './$types';
 | 
			
		||||
 | 
			
		||||
  interface Props {
 | 
			
		||||
    data: PageData;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let { data }: Props = $props();
 | 
			
		||||
 | 
			
		||||
  const assetStore = new AssetStore();
 | 
			
		||||
  void assetStore.updateOptions({ visibility: AssetVisibility.Locked });
 | 
			
		||||
  onDestroy(() => assetStore.destroy());
 | 
			
		||||
 | 
			
		||||
  const assetInteraction = new AssetInteraction();
 | 
			
		||||
 | 
			
		||||
  const handleEscape = () => {
 | 
			
		||||
    if (assetInteraction.selectionActive) {
 | 
			
		||||
      assetInteraction.clearMultiselect();
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleMoveOffLockedFolder = (assetIds: string[]) => {
 | 
			
		||||
    assetInteraction.clearMultiselect();
 | 
			
		||||
    assetStore.removeAssets(assetIds);
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<!-- Multi-selection mode app bar -->
 | 
			
		||||
{#if assetInteraction.selectionActive}
 | 
			
		||||
  <AssetSelectControlBar
 | 
			
		||||
    assets={assetInteraction.selectedAssets}
 | 
			
		||||
    clearSelect={() => assetInteraction.clearMultiselect()}
 | 
			
		||||
  >
 | 
			
		||||
    <SelectAllAssets withText {assetStore} {assetInteraction} />
 | 
			
		||||
    <SetVisibilityAction unlock onVisibilitySet={handleMoveOffLockedFolder} />
 | 
			
		||||
    <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
 | 
			
		||||
      <DownloadAction menuItem />
 | 
			
		||||
      <ChangeDate menuItem />
 | 
			
		||||
      <ChangeLocation menuItem />
 | 
			
		||||
      <DeleteAssets menuItem force onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
			
		||||
    </ButtonContextMenu>
 | 
			
		||||
  </AssetSelectControlBar>
 | 
			
		||||
{/if}
 | 
			
		||||
 | 
			
		||||
<UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}>
 | 
			
		||||
  <AssetGrid
 | 
			
		||||
    enableRouting={true}
 | 
			
		||||
    {assetStore}
 | 
			
		||||
    {assetInteraction}
 | 
			
		||||
    onEscape={handleEscape}
 | 
			
		||||
    removeAction={AssetAction.SET_VISIBILITY_TIMELINE}
 | 
			
		||||
  >
 | 
			
		||||
    {#snippet empty()}
 | 
			
		||||
      <EmptyPlaceholder text={$t('no_locked_photos_message')} title={$t('nothing_here_yet')} />
 | 
			
		||||
    {/snippet}
 | 
			
		||||
  </AssetGrid>
 | 
			
		||||
</UserPageLayout>
 | 
			
		||||
@ -0,0 +1,28 @@
 | 
			
		||||
import { AppRoute } from '$lib/constants';
 | 
			
		||||
import { authenticate } from '$lib/utils/auth';
 | 
			
		||||
import { getFormatter } from '$lib/utils/i18n';
 | 
			
		||||
import { getAssetInfoFromParam } from '$lib/utils/navigation';
 | 
			
		||||
import { getAuthStatus } from '@immich/sdk';
 | 
			
		||||
import { redirect } from '@sveltejs/kit';
 | 
			
		||||
import type { PageLoad } from './$types';
 | 
			
		||||
 | 
			
		||||
export const load = (async ({ params, url }) => {
 | 
			
		||||
  await authenticate();
 | 
			
		||||
  const { isElevated, pinCode } = await getAuthStatus();
 | 
			
		||||
 | 
			
		||||
  if (!isElevated || !pinCode) {
 | 
			
		||||
    const continuePath = encodeURIComponent(url.pathname);
 | 
			
		||||
    const redirectPath = `${AppRoute.AUTH_PIN_PROMPT}?continue=${continuePath}`;
 | 
			
		||||
 | 
			
		||||
    redirect(302, redirectPath);
 | 
			
		||||
  }
 | 
			
		||||
  const asset = await getAssetInfoFromParam(params);
 | 
			
		||||
  const $t = await getFormatter();
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    asset,
 | 
			
		||||
    meta: {
 | 
			
		||||
      title: $t('locked_folder'),
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
}) satisfies PageLoad;
 | 
			
		||||
@ -12,6 +12,7 @@
 | 
			
		||||
  import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
 | 
			
		||||
  import LinkLivePhotoAction from '$lib/components/photos-page/actions/link-live-photo-action.svelte';
 | 
			
		||||
  import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
 | 
			
		||||
  import SetVisibilityAction from '$lib/components/photos-page/actions/set-visibility-action.svelte';
 | 
			
		||||
  import StackAction from '$lib/components/photos-page/actions/stack-action.svelte';
 | 
			
		||||
  import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
 | 
			
		||||
  import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
 | 
			
		||||
@ -75,6 +76,11 @@
 | 
			
		||||
    assetStore.updateAssets([still]);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSetVisibility = (assetIds: string[]) => {
 | 
			
		||||
    assetStore.removeAssets(assetIds);
 | 
			
		||||
    assetInteraction.clearMultiselect();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  beforeNavigate(() => {
 | 
			
		||||
    isFaceEditMode.value = false;
 | 
			
		||||
  });
 | 
			
		||||
@ -142,6 +148,7 @@
 | 
			
		||||
        <TagAction menuItem />
 | 
			
		||||
      {/if}
 | 
			
		||||
      <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
			
		||||
      <SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
 | 
			
		||||
      <hr />
 | 
			
		||||
      <AssetJobActions />
 | 
			
		||||
    </ButtonContextMenu>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										84
									
								
								web/src/routes/auth/pin-prompt/+page.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								web/src/routes/auth/pin-prompt/+page.svelte
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,84 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import { goto } from '$app/navigation';
 | 
			
		||||
  import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
 | 
			
		||||
  import PinCodeCreateForm from '$lib/components/user-settings-page/PinCodeCreateForm.svelte';
 | 
			
		||||
  import PincodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
 | 
			
		||||
  import { AppRoute } from '$lib/constants';
 | 
			
		||||
  import { handleError } from '$lib/utils/handle-error';
 | 
			
		||||
  import { verifyPinCode } from '@immich/sdk';
 | 
			
		||||
  import { Icon } from '@immich/ui';
 | 
			
		||||
  import { mdiLockOpenVariantOutline, mdiLockOutline, mdiLockSmart } from '@mdi/js';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
  import { fade } from 'svelte/transition';
 | 
			
		||||
  import type { PageData } from './$types';
 | 
			
		||||
 | 
			
		||||
  interface Props {
 | 
			
		||||
    data: PageData;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let { data }: Props = $props();
 | 
			
		||||
 | 
			
		||||
  let isVerified = $state(false);
 | 
			
		||||
  let isBadPinCode = $state(false);
 | 
			
		||||
  let hasPinCode = $derived(data.hasPinCode);
 | 
			
		||||
  let pinCode = $state('');
 | 
			
		||||
 | 
			
		||||
  const onPinFilled = async (code: string, withDelay = false) => {
 | 
			
		||||
    try {
 | 
			
		||||
      await verifyPinCode({ pinCodeSetupDto: { pinCode: code } });
 | 
			
		||||
 | 
			
		||||
      isVerified = true;
 | 
			
		||||
 | 
			
		||||
      if (withDelay) {
 | 
			
		||||
        await new Promise((resolve) => setTimeout(resolve, 1000));
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      void goto(data.continuePath ?? AppRoute.LOCKED);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, $t('wrong_pin_code'));
 | 
			
		||||
      isBadPinCode = true;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<AuthPageLayout withHeader={false}>
 | 
			
		||||
  {#if hasPinCode}
 | 
			
		||||
    <div class="flex items-center justify-center">
 | 
			
		||||
      <div class="w-96 flex flex-col gap-6 items-center justify-center">
 | 
			
		||||
        {#if isVerified}
 | 
			
		||||
          <div in:fade={{ duration: 200 }}>
 | 
			
		||||
            <Icon icon={mdiLockOpenVariantOutline} size="64" class="text-success/90" />
 | 
			
		||||
          </div>
 | 
			
		||||
        {:else}
 | 
			
		||||
          <div class:text-danger={isBadPinCode} class:text-primary={!isBadPinCode}>
 | 
			
		||||
            <Icon icon={mdiLockOutline} size="64" />
 | 
			
		||||
          </div>
 | 
			
		||||
        {/if}
 | 
			
		||||
 | 
			
		||||
        <p class="text-center text-sm" style="text-wrap: pretty;">{$t('enter_your_pin_code_subtitle')}</p>
 | 
			
		||||
 | 
			
		||||
        <PincodeInput
 | 
			
		||||
          type="password"
 | 
			
		||||
          autofocus
 | 
			
		||||
          label=""
 | 
			
		||||
          bind:value={pinCode}
 | 
			
		||||
          tabindexStart={1}
 | 
			
		||||
          pinLength={6}
 | 
			
		||||
          onFilled={(pinCode) => onPinFilled(pinCode, true)}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  {:else}
 | 
			
		||||
    <div class="flex items-center justify-center">
 | 
			
		||||
      <div class="w-96 flex flex-col gap-6 items-center justify-center">
 | 
			
		||||
        <div class="text-primary">
 | 
			
		||||
          <Icon icon={mdiLockSmart} size="64" />
 | 
			
		||||
        </div>
 | 
			
		||||
        <p class="text-center text-sm mb-4" style="text-wrap: pretty;">
 | 
			
		||||
          {$t('new_pin_code_subtitle')}
 | 
			
		||||
        </p>
 | 
			
		||||
        <PinCodeCreateForm showLabel={false} onCreated={() => (hasPinCode = true)} />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  {/if}
 | 
			
		||||
</AuthPageLayout>
 | 
			
		||||
							
								
								
									
										22
									
								
								web/src/routes/auth/pin-prompt/+page.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								web/src/routes/auth/pin-prompt/+page.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,22 @@
 | 
			
		||||
import { authenticate } from '$lib/utils/auth';
 | 
			
		||||
import { getFormatter } from '$lib/utils/i18n';
 | 
			
		||||
import { getAuthStatus } from '@immich/sdk';
 | 
			
		||||
import type { PageLoad } from './$types';
 | 
			
		||||
 | 
			
		||||
export const load = (async ({ url }) => {
 | 
			
		||||
  await authenticate();
 | 
			
		||||
 | 
			
		||||
  const { pinCode } = await getAuthStatus();
 | 
			
		||||
 | 
			
		||||
  const continuePath = url.searchParams.get('continue');
 | 
			
		||||
 | 
			
		||||
  const $t = await getFormatter();
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    meta: {
 | 
			
		||||
      title: $t('pin_verification'),
 | 
			
		||||
    },
 | 
			
		||||
    hasPinCode: !!pinCode,
 | 
			
		||||
    continuePath,
 | 
			
		||||
  };
 | 
			
		||||
}) satisfies PageLoad;
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
import { faker } from '@faker-js/faker';
 | 
			
		||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
 | 
			
		||||
import { AssetTypeEnum, Visibility, type AssetResponseDto } from '@immich/sdk';
 | 
			
		||||
import { Sync } from 'factory.ts';
 | 
			
		||||
 | 
			
		||||
export const assetFactory = Sync.makeFactory<AssetResponseDto>({
 | 
			
		||||
@ -24,4 +24,5 @@ export const assetFactory = Sync.makeFactory<AssetResponseDto>({
 | 
			
		||||
  checksum: Sync.each(() => faker.string.alphanumeric(28)),
 | 
			
		||||
  isOffline: Sync.each(() => faker.datatype.boolean()),
 | 
			
		||||
  hasMetadata: Sync.each(() => faker.datatype.boolean()),
 | 
			
		||||
  visibility: Visibility.Timeline,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user