mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04: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": "PIN Code",
|
||||||
"user_pin_code_settings_description": "Manage your PIN code",
|
"user_pin_code_settings_description": "Manage your PIN code",
|
||||||
"current_pin_code": "Current PIN code",
|
"current_pin_code": "Current PIN code",
|
||||||
@ -837,6 +852,7 @@
|
|||||||
"error_saving_image": "Error: {error}",
|
"error_saving_image": "Error: {error}",
|
||||||
"error_title": "Error - Something went wrong",
|
"error_title": "Error - Something went wrong",
|
||||||
"errors": {
|
"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_next_asset": "Cannot navigate to the next asset",
|
||||||
"cannot_navigate_previous_asset": "Cannot navigate to previous asset",
|
"cannot_navigate_previous_asset": "Cannot navigate to previous asset",
|
||||||
"cant_apply_changes": "Can't apply changes",
|
"cant_apply_changes": "Can't apply changes",
|
||||||
|
@ -29,6 +29,7 @@ dynamic upgradeDto(dynamic value, String targetType) {
|
|||||||
case 'UserResponseDto':
|
case 'UserResponseDto':
|
||||||
if (value is Map) {
|
if (value is Map) {
|
||||||
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
|
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
|
||||||
|
addDefault(value, 'visibility', AssetVisibility.timeline);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'UserAdminResponseDto':
|
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* | [**setupPinCode**](doc//AuthenticationApi.md#setuppincode) | **POST** /auth/pin-code |
|
||||||
*AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up |
|
*AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up |
|
||||||
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
|
*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 |
|
*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random |
|
||||||
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive |
|
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive |
|
||||||
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info |
|
*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;
|
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,
|
required this.type,
|
||||||
this.unassignedFaces = const [],
|
this.unassignedFaces = const [],
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
|
required this.visibility,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// base64 encoded sha1 hash
|
/// base64 encoded sha1 hash
|
||||||
@ -132,6 +133,8 @@ class AssetResponseDto {
|
|||||||
|
|
||||||
DateTime updatedAt;
|
DateTime updatedAt;
|
||||||
|
|
||||||
|
AssetResponseDtoVisibilityEnum visibility;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
|
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
|
||||||
other.checksum == checksum &&
|
other.checksum == checksum &&
|
||||||
@ -163,7 +166,8 @@ class AssetResponseDto {
|
|||||||
other.thumbhash == thumbhash &&
|
other.thumbhash == thumbhash &&
|
||||||
other.type == type &&
|
other.type == type &&
|
||||||
_deepEquality.equals(other.unassignedFaces, unassignedFaces) &&
|
_deepEquality.equals(other.unassignedFaces, unassignedFaces) &&
|
||||||
other.updatedAt == updatedAt;
|
other.updatedAt == updatedAt &&
|
||||||
|
other.visibility == visibility;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
@ -197,10 +201,11 @@ class AssetResponseDto {
|
|||||||
(thumbhash == null ? 0 : thumbhash!.hashCode) +
|
(thumbhash == null ? 0 : thumbhash!.hashCode) +
|
||||||
(type.hashCode) +
|
(type.hashCode) +
|
||||||
(unassignedFaces.hashCode) +
|
(unassignedFaces.hashCode) +
|
||||||
(updatedAt.hashCode);
|
(updatedAt.hashCode) +
|
||||||
|
(visibility.hashCode);
|
||||||
|
|
||||||
@override
|
@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() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@ -270,6 +275,7 @@ class AssetResponseDto {
|
|||||||
json[r'type'] = this.type;
|
json[r'type'] = this.type;
|
||||||
json[r'unassignedFaces'] = this.unassignedFaces;
|
json[r'unassignedFaces'] = this.unassignedFaces;
|
||||||
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
|
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
|
||||||
|
json[r'visibility'] = this.visibility;
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -312,6 +318,7 @@ class AssetResponseDto {
|
|||||||
type: AssetTypeEnum.fromJson(json[r'type'])!,
|
type: AssetTypeEnum.fromJson(json[r'type'])!,
|
||||||
unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
|
unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
|
||||||
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
|
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
|
||||||
|
visibility: AssetResponseDtoVisibilityEnum.fromJson(json[r'visibility'])!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -378,6 +385,87 @@ class AssetResponseDto {
|
|||||||
'thumbhash',
|
'thumbhash',
|
||||||
'type',
|
'type',
|
||||||
'updatedAt',
|
'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 archive = AssetVisibility._(r'archive');
|
||||||
static const timeline = AssetVisibility._(r'timeline');
|
static const timeline = AssetVisibility._(r'timeline');
|
||||||
static const hidden = AssetVisibility._(r'hidden');
|
static const hidden = AssetVisibility._(r'hidden');
|
||||||
|
static const locked = AssetVisibility._(r'locked');
|
||||||
|
|
||||||
/// List of all possible values in this [enum][AssetVisibility].
|
/// List of all possible values in this [enum][AssetVisibility].
|
||||||
static const values = <AssetVisibility>[
|
static const values = <AssetVisibility>[
|
||||||
archive,
|
archive,
|
||||||
timeline,
|
timeline,
|
||||||
hidden,
|
hidden,
|
||||||
|
locked,
|
||||||
];
|
];
|
||||||
|
|
||||||
static AssetVisibility? fromJson(dynamic value) => AssetVisibilityTypeTransformer().decode(value);
|
static AssetVisibility? fromJson(dynamic value) => AssetVisibilityTypeTransformer().decode(value);
|
||||||
@ -73,6 +75,7 @@ class AssetVisibilityTypeTransformer {
|
|||||||
case r'archive': return AssetVisibility.archive;
|
case r'archive': return AssetVisibility.archive;
|
||||||
case r'timeline': return AssetVisibility.timeline;
|
case r'timeline': return AssetVisibility.timeline;
|
||||||
case r'hidden': return AssetVisibility.hidden;
|
case r'hidden': return AssetVisibility.hidden;
|
||||||
|
case r'locked': return AssetVisibility.locked;
|
||||||
default:
|
default:
|
||||||
if (!allowNull) {
|
if (!allowNull) {
|
||||||
throw ArgumentError('Unknown enum value to decode: $data');
|
throw ArgumentError('Unknown enum value to decode: $data');
|
||||||
|
@ -13,30 +13,36 @@ part of openapi.api;
|
|||||||
class AuthStatusResponseDto {
|
class AuthStatusResponseDto {
|
||||||
/// Returns a new [AuthStatusResponseDto] instance.
|
/// Returns a new [AuthStatusResponseDto] instance.
|
||||||
AuthStatusResponseDto({
|
AuthStatusResponseDto({
|
||||||
|
required this.isElevated,
|
||||||
required this.password,
|
required this.password,
|
||||||
required this.pinCode,
|
required this.pinCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool isElevated;
|
||||||
|
|
||||||
bool password;
|
bool password;
|
||||||
|
|
||||||
bool pinCode;
|
bool pinCode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is AuthStatusResponseDto &&
|
bool operator ==(Object other) => identical(this, other) || other is AuthStatusResponseDto &&
|
||||||
|
other.isElevated == isElevated &&
|
||||||
other.password == password &&
|
other.password == password &&
|
||||||
other.pinCode == pinCode;
|
other.pinCode == pinCode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
|
(isElevated.hashCode) +
|
||||||
(password.hashCode) +
|
(password.hashCode) +
|
||||||
(pinCode.hashCode);
|
(pinCode.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'AuthStatusResponseDto[password=$password, pinCode=$pinCode]';
|
String toString() => 'AuthStatusResponseDto[isElevated=$isElevated, password=$password, pinCode=$pinCode]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
|
json[r'isElevated'] = this.isElevated;
|
||||||
json[r'password'] = this.password;
|
json[r'password'] = this.password;
|
||||||
json[r'pinCode'] = this.pinCode;
|
json[r'pinCode'] = this.pinCode;
|
||||||
return json;
|
return json;
|
||||||
@ -51,6 +57,7 @@ class AuthStatusResponseDto {
|
|||||||
final json = value.cast<String, dynamic>();
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
return AuthStatusResponseDto(
|
return AuthStatusResponseDto(
|
||||||
|
isElevated: mapValueOfType<bool>(json, r'isElevated')!,
|
||||||
password: mapValueOfType<bool>(json, r'password')!,
|
password: mapValueOfType<bool>(json, r'password')!,
|
||||||
pinCode: mapValueOfType<bool>(json, r'pinCode')!,
|
pinCode: mapValueOfType<bool>(json, r'pinCode')!,
|
||||||
);
|
);
|
||||||
@ -100,6 +107,7 @@ class AuthStatusResponseDto {
|
|||||||
|
|
||||||
/// The list of required keys that must be present in a JSON.
|
/// The list of required keys that must be present in a JSON.
|
||||||
static const requiredKeys = <String>{
|
static const requiredKeys = <String>{
|
||||||
|
'isElevated',
|
||||||
'password',
|
'password',
|
||||||
'pinCode',
|
'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 archive = SyncAssetV1VisibilityEnum._(r'archive');
|
||||||
static const timeline = SyncAssetV1VisibilityEnum._(r'timeline');
|
static const timeline = SyncAssetV1VisibilityEnum._(r'timeline');
|
||||||
static const hidden = SyncAssetV1VisibilityEnum._(r'hidden');
|
static const hidden = SyncAssetV1VisibilityEnum._(r'hidden');
|
||||||
|
static const locked = SyncAssetV1VisibilityEnum._(r'locked');
|
||||||
|
|
||||||
/// List of all possible values in this [enum][SyncAssetV1VisibilityEnum].
|
/// List of all possible values in this [enum][SyncAssetV1VisibilityEnum].
|
||||||
static const values = <SyncAssetV1VisibilityEnum>[
|
static const values = <SyncAssetV1VisibilityEnum>[
|
||||||
archive,
|
archive,
|
||||||
timeline,
|
timeline,
|
||||||
hidden,
|
hidden,
|
||||||
|
locked,
|
||||||
];
|
];
|
||||||
|
|
||||||
static SyncAssetV1VisibilityEnum? fromJson(dynamic value) => SyncAssetV1VisibilityEnumTypeTransformer().decode(value);
|
static SyncAssetV1VisibilityEnum? fromJson(dynamic value) => SyncAssetV1VisibilityEnumTypeTransformer().decode(value);
|
||||||
@ -340,6 +342,7 @@ class SyncAssetV1VisibilityEnumTypeTransformer {
|
|||||||
case r'archive': return SyncAssetV1VisibilityEnum.archive;
|
case r'archive': return SyncAssetV1VisibilityEnum.archive;
|
||||||
case r'timeline': return SyncAssetV1VisibilityEnum.timeline;
|
case r'timeline': return SyncAssetV1VisibilityEnum.timeline;
|
||||||
case r'hidden': return SyncAssetV1VisibilityEnum.hidden;
|
case r'hidden': return SyncAssetV1VisibilityEnum.hidden;
|
||||||
|
case r'locked': return SyncAssetV1VisibilityEnum.locked;
|
||||||
default:
|
default:
|
||||||
if (!allowNull) {
|
if (!allowNull) {
|
||||||
throw ArgumentError('Unknown enum value to decode: $data');
|
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": {
|
"/auth/status": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getAuthStatus",
|
"operationId": "getAuthStatus",
|
||||||
@ -9150,6 +9185,15 @@
|
|||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"enum": [
|
||||||
|
"archive",
|
||||||
|
"timeline",
|
||||||
|
"hidden",
|
||||||
|
"locked"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@ -9171,7 +9215,8 @@
|
|||||||
"ownerId",
|
"ownerId",
|
||||||
"thumbhash",
|
"thumbhash",
|
||||||
"type",
|
"type",
|
||||||
"updatedAt"
|
"updatedAt",
|
||||||
|
"visibility"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@ -9226,7 +9271,8 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"archive",
|
"archive",
|
||||||
"timeline",
|
"timeline",
|
||||||
"hidden"
|
"hidden",
|
||||||
|
"locked"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@ -9241,6 +9287,9 @@
|
|||||||
},
|
},
|
||||||
"AuthStatusResponseDto": {
|
"AuthStatusResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"isElevated": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@ -9249,6 +9298,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"isElevated",
|
||||||
"password",
|
"password",
|
||||||
"pinCode"
|
"pinCode"
|
||||||
],
|
],
|
||||||
@ -12664,7 +12714,8 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"archive",
|
"archive",
|
||||||
"timeline",
|
"timeline",
|
||||||
"hidden"
|
"hidden",
|
||||||
|
"locked"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
@ -329,6 +329,7 @@ export type AssetResponseDto = {
|
|||||||
"type": AssetTypeEnum;
|
"type": AssetTypeEnum;
|
||||||
unassignedFaces?: AssetFaceWithoutPersonResponseDto[];
|
unassignedFaces?: AssetFaceWithoutPersonResponseDto[];
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
visibility: Visibility;
|
||||||
};
|
};
|
||||||
export type AlbumResponseDto = {
|
export type AlbumResponseDto = {
|
||||||
albumName: string;
|
albumName: string;
|
||||||
@ -520,6 +521,7 @@ export type PinCodeSetupDto = {
|
|||||||
pinCode: string;
|
pinCode: string;
|
||||||
};
|
};
|
||||||
export type AuthStatusResponseDto = {
|
export type AuthStatusResponseDto = {
|
||||||
|
isElevated: boolean;
|
||||||
password: boolean;
|
password: boolean;
|
||||||
pinCode: boolean;
|
pinCode: boolean;
|
||||||
};
|
};
|
||||||
@ -2076,6 +2078,15 @@ export function changePinCode({ pinCodeChangeDto }: {
|
|||||||
body: 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) {
|
export function getAuthStatus(opts?: Oazapfts.RequestOpts) {
|
||||||
return oazapfts.ok(oazapfts.fetchJson<{
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
status: 200;
|
status: 200;
|
||||||
@ -3574,7 +3585,8 @@ export enum UserStatus {
|
|||||||
export enum AssetVisibility {
|
export enum AssetVisibility {
|
||||||
Archive = "archive",
|
Archive = "archive",
|
||||||
Timeline = "timeline",
|
Timeline = "timeline",
|
||||||
Hidden = "hidden"
|
Hidden = "hidden",
|
||||||
|
Locked = "locked"
|
||||||
}
|
}
|
||||||
export enum AlbumUserRole {
|
export enum AlbumUserRole {
|
||||||
Editor = "editor",
|
Editor = "editor",
|
||||||
@ -3591,6 +3603,12 @@ export enum AssetTypeEnum {
|
|||||||
Audio = "AUDIO",
|
Audio = "AUDIO",
|
||||||
Other = "OTHER"
|
Other = "OTHER"
|
||||||
}
|
}
|
||||||
|
export enum Visibility {
|
||||||
|
Archive = "archive",
|
||||||
|
Timeline = "timeline",
|
||||||
|
Hidden = "hidden",
|
||||||
|
Locked = "locked"
|
||||||
|
}
|
||||||
export enum AssetOrder {
|
export enum AssetOrder {
|
||||||
Asc = "asc",
|
Asc = "asc",
|
||||||
Desc = "desc"
|
Desc = "desc"
|
||||||
|
@ -101,4 +101,11 @@ export class AuthController {
|
|||||||
async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
|
async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
|
||||||
return this.service.resetPinCode(auth, dto);
|
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' });
|
.send({ visibility: 'immich' });
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(
|
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 = {
|
export type AuthSession = {
|
||||||
id: string;
|
id: string;
|
||||||
|
hasElevatedPermission: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Partner = {
|
export type Partner = {
|
||||||
@ -233,6 +234,7 @@ export type Session = {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
deviceOS: string;
|
deviceOS: string;
|
||||||
deviceType: string;
|
deviceType: string;
|
||||||
|
pinExpiresAt: Date | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Exif = Omit<Selectable<DatabaseExif>, 'updatedAt' | 'updateId'>;
|
export type Exif = Omit<Selectable<DatabaseExif>, 'updatedAt' | 'updateId'>;
|
||||||
@ -306,7 +308,7 @@ export const columns = {
|
|||||||
'users.quotaSizeInBytes',
|
'users.quotaSizeInBytes',
|
||||||
],
|
],
|
||||||
authApiKey: ['api_keys.id', 'api_keys.permissions'],
|
authApiKey: ['api_keys.id', 'api_keys.permissions'],
|
||||||
authSession: ['sessions.id', 'sessions.updatedAt'],
|
authSession: ['sessions.id', 'sessions.updatedAt', 'sessions.pinExpiresAt'],
|
||||||
authSharedLink: [
|
authSharedLink: [
|
||||||
'shared_links.id',
|
'shared_links.id',
|
||||||
'shared_links.userId',
|
'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>;
|
updatedAt: Generated<Timestamp>;
|
||||||
updateId: Generated<string>;
|
updateId: Generated<string>;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
pinExpiresAt: Timestamp | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionSyncCheckpoints {
|
export interface SessionSyncCheckpoints {
|
||||||
|
@ -43,6 +43,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
|
|||||||
isArchived!: boolean;
|
isArchived!: boolean;
|
||||||
isTrashed!: boolean;
|
isTrashed!: boolean;
|
||||||
isOffline!: boolean;
|
isOffline!: boolean;
|
||||||
|
visibility!: AssetVisibility;
|
||||||
exifInfo?: ExifResponseDto;
|
exifInfo?: ExifResponseDto;
|
||||||
tags?: TagResponseDto[];
|
tags?: TagResponseDto[];
|
||||||
people?: PersonWithFacesResponseDto[];
|
people?: PersonWithFacesResponseDto[];
|
||||||
@ -184,6 +185,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
|||||||
isFavorite: options.auth?.user.id === entity.ownerId ? entity.isFavorite : false,
|
isFavorite: options.auth?.user.id === entity.ownerId ? entity.isFavorite : false,
|
||||||
isArchived: entity.visibility === AssetVisibility.ARCHIVE,
|
isArchived: entity.visibility === AssetVisibility.ARCHIVE,
|
||||||
isTrashed: !!entity.deletedAt,
|
isTrashed: !!entity.deletedAt,
|
||||||
|
visibility: entity.visibility,
|
||||||
duration: entity.duration ?? '0:00:00.00000',
|
duration: entity.duration ?? '0:00:00.00000',
|
||||||
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
|
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
|
||||||
livePhotoVideoId: entity.livePhotoVideoId,
|
livePhotoVideoId: entity.livePhotoVideoId,
|
||||||
|
@ -138,4 +138,5 @@ export class OAuthAuthorizeResponseDto {
|
|||||||
export class AuthStatusResponseDto {
|
export class AuthStatusResponseDto {
|
||||||
pinCode!: boolean;
|
pinCode!: boolean;
|
||||||
password!: boolean;
|
password!: boolean;
|
||||||
|
isElevated!: boolean;
|
||||||
}
|
}
|
||||||
|
@ -627,4 +627,5 @@ export enum AssetVisibility {
|
|||||||
* Video part of the LivePhotos and MotionPhotos
|
* Video part of the LivePhotos and MotionPhotos
|
||||||
*/
|
*/
|
||||||
HIDDEN = 'hidden',
|
HIDDEN = 'hidden',
|
||||||
|
LOCKED = 'locked',
|
||||||
}
|
}
|
||||||
|
@ -98,6 +98,7 @@ from
|
|||||||
where
|
where
|
||||||
"assets"."id" in ($1)
|
"assets"."id" in ($1)
|
||||||
and "assets"."ownerId" = $2
|
and "assets"."ownerId" = $2
|
||||||
|
and "assets"."visibility" != $3
|
||||||
|
|
||||||
-- AccessRepository.asset.checkPartnerAccess
|
-- AccessRepository.asset.checkPartnerAccess
|
||||||
select
|
select
|
||||||
|
@ -392,6 +392,11 @@ where
|
|||||||
order by
|
order by
|
||||||
"albums"."createdAt" desc
|
"albums"."createdAt" desc
|
||||||
|
|
||||||
|
-- AlbumRepository.removeAssetsFromAll
|
||||||
|
delete from "albums_assets_assets"
|
||||||
|
where
|
||||||
|
"albums_assets_assets"."assetsId" in ($1)
|
||||||
|
|
||||||
-- AlbumRepository.getAssetIds
|
-- AlbumRepository.getAssetIds
|
||||||
select
|
select
|
||||||
*
|
*
|
||||||
|
@ -12,6 +12,7 @@ where
|
|||||||
select
|
select
|
||||||
"sessions"."id",
|
"sessions"."id",
|
||||||
"sessions"."updatedAt",
|
"sessions"."updatedAt",
|
||||||
|
"sessions"."pinExpiresAt",
|
||||||
(
|
(
|
||||||
select
|
select
|
||||||
to_json(obj)
|
to_json(obj)
|
||||||
|
@ -168,7 +168,7 @@ class AssetAccess {
|
|||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
||||||
@ChunkedSet({ paramIndex: 1 })
|
@ChunkedSet({ paramIndex: 1 })
|
||||||
async checkOwnerAccess(userId: string, assetIds: Set<string>) {
|
async checkOwnerAccess(userId: string, assetIds: Set<string>, hasElevatedPermission: boolean | undefined) {
|
||||||
if (assetIds.size === 0) {
|
if (assetIds.size === 0) {
|
||||||
return new Set<string>();
|
return new Set<string>();
|
||||||
}
|
}
|
||||||
@ -178,6 +178,7 @@ class AssetAccess {
|
|||||||
.select('assets.id')
|
.select('assets.id')
|
||||||
.where('assets.id', 'in', [...assetIds])
|
.where('assets.id', 'in', [...assetIds])
|
||||||
.where('assets.ownerId', '=', userId)
|
.where('assets.ownerId', '=', userId)
|
||||||
|
.$if(!hasElevatedPermission, (eb) => eb.where('assets.visibility', '!=', AssetVisibility.LOCKED))
|
||||||
.execute()
|
.execute()
|
||||||
.then((assets) => new Set(assets.map((asset) => asset.id)));
|
.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();
|
await this.db.deleteFrom('albums').where('ownerId', '=', userId).execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeAsset(assetId: string): Promise<void> {
|
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||||
await this.db.deleteFrom('albums_assets_assets').where('albums_assets_assets.assetsId', '=', assetId).execute();
|
@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 })
|
@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' })
|
@UpdateIdColumn({ indexName: 'IDX_sessions_update_id' })
|
||||||
updateId!: string;
|
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.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', {
|
expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', {
|
||||||
id: albumStub.empty.id,
|
id: albumStub.empty.id,
|
||||||
userId: 'user-id',
|
userId: 'user-id',
|
||||||
@ -207,6 +207,7 @@ describe(AlbumService.name, () => {
|
|||||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||||
authStub.admin.user.id,
|
authStub.admin.user.id,
|
||||||
new Set(['asset-1', 'asset-2']),
|
new Set(['asset-1', 'asset-2']),
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -688,7 +689,11 @@ describe(AlbumService.name, () => {
|
|||||||
{ success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION },
|
{ 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']));
|
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 () => {
|
it('should require the asset.download permission', async () => {
|
||||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
|
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.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||||
expect(mocks.access.asset.checkPartnerAccess).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 () => {
|
it('should require asset.view permissions', async () => {
|
||||||
await expect(sut.viewThumbnail(authStub.admin, 'id', {})).rejects.toBeInstanceOf(BadRequestException);
|
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.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||||
expect(mocks.access.asset.checkPartnerAccess).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 () => {
|
it('should require asset.view permissions', async () => {
|
||||||
await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException);
|
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.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||||
expect(mocks.access.asset.checkPartnerAccess).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(
|
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||||
authStub.admin.user.id,
|
authStub.admin.user.id,
|
||||||
new Set([assetStub.image.id]),
|
new Set([assetStub.image.id]),
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ import {
|
|||||||
mapStats,
|
mapStats,
|
||||||
} from 'src/dtos/asset.dto';
|
} from 'src/dtos/asset.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.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 { BaseService } from 'src/services/base.service';
|
||||||
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
|
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
|
||||||
import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util';
|
import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util';
|
||||||
@ -125,6 +125,10 @@ export class AssetService extends BaseService {
|
|||||||
options.rating !== undefined
|
options.rating !== undefined
|
||||||
) {
|
) {
|
||||||
await this.assetRepository.updateAll(ids, options);
|
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,
|
id: session.id,
|
||||||
updatedAt: session.updatedAt,
|
updatedAt: session.updatedAt,
|
||||||
user: factory.authUser(),
|
user: factory.authUser(),
|
||||||
|
pinExpiresAt: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
||||||
@ -265,7 +266,7 @@ describe(AuthService.name, () => {
|
|||||||
}),
|
}),
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
user: sessionWithToken.user,
|
user: sessionWithToken.user,
|
||||||
session: { id: session.id },
|
session: { id: session.id, hasElevatedPermission: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -376,6 +377,7 @@ describe(AuthService.name, () => {
|
|||||||
id: session.id,
|
id: session.id,
|
||||||
updatedAt: session.updatedAt,
|
updatedAt: session.updatedAt,
|
||||||
user: factory.authUser(),
|
user: factory.authUser(),
|
||||||
|
pinExpiresAt: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
||||||
@ -388,7 +390,7 @@ describe(AuthService.name, () => {
|
|||||||
}),
|
}),
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
user: sessionWithToken.user,
|
user: sessionWithToken.user,
|
||||||
session: { id: session.id },
|
session: { id: session.id, hasElevatedPermission: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -398,6 +400,7 @@ describe(AuthService.name, () => {
|
|||||||
id: session.id,
|
id: session.id,
|
||||||
updatedAt: session.updatedAt,
|
updatedAt: session.updatedAt,
|
||||||
user: factory.authUser(),
|
user: factory.authUser(),
|
||||||
|
pinExpiresAt: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
||||||
@ -417,6 +420,7 @@ describe(AuthService.name, () => {
|
|||||||
id: session.id,
|
id: session.id,
|
||||||
updatedAt: session.updatedAt,
|
updatedAt: session.updatedAt,
|
||||||
user: factory.authUser(),
|
user: factory.authUser(),
|
||||||
|
pinExpiresAt: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
||||||
@ -916,13 +920,17 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
describe('resetPinCode', () => {
|
describe('resetPinCode', () => {
|
||||||
it('should reset the PIN code', async () => {
|
it('should reset the PIN code', async () => {
|
||||||
|
const currentSession = factory.session();
|
||||||
const user = factory.userAdmin();
|
const user = factory.userAdmin();
|
||||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||||
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
|
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' });
|
await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' });
|
||||||
|
|
||||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null });
|
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 () => {
|
it('should throw if the PIN code does not match', async () => {
|
||||||
|
@ -126,6 +126,10 @@ export class AuthService extends BaseService {
|
|||||||
this.resetPinChecks(user, dto);
|
this.resetPinChecks(user, dto);
|
||||||
|
|
||||||
await this.userRepository.update(auth.user.id, { pinCode: null });
|
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) {
|
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() });
|
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 {
|
return {
|
||||||
user: session.user,
|
user: session.user,
|
||||||
session: {
|
session: {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
|
hasElevatedPermission,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -455,6 +474,23 @@ export class AuthService extends BaseService {
|
|||||||
throw new UnauthorizedException('Invalid user token');
|
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) {
|
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) {
|
||||||
const key = this.cryptoRepository.newPassword(32);
|
const key = this.cryptoRepository.newPassword(32);
|
||||||
const token = this.cryptoRepository.hashSha256(key);
|
const token = this.cryptoRepository.hashSha256(key);
|
||||||
@ -493,6 +529,7 @@ export class AuthService extends BaseService {
|
|||||||
return {
|
return {
|
||||||
pinCode: !!user.pinCode,
|
pinCode: !!user.pinCode,
|
||||||
password: !!user.password,
|
password: !!user.password,
|
||||||
|
isElevated: !!auth.session?.hasElevatedPermission,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1310,7 +1310,7 @@ describe(MetadataService.name, () => {
|
|||||||
expect(mocks.asset.update).not.toHaveBeenCalledWith(
|
expect(mocks.asset.update).not.toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ visibility: AssetVisibility.HIDDEN }),
|
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 () => {
|
it('should handle not finding a match', async () => {
|
||||||
@ -1331,7 +1331,7 @@ describe(MetadataService.name, () => {
|
|||||||
expect(mocks.asset.update).not.toHaveBeenCalledWith(
|
expect(mocks.asset.update).not.toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ visibility: AssetVisibility.HIDDEN }),
|
expect.objectContaining({ visibility: AssetVisibility.HIDDEN }),
|
||||||
);
|
);
|
||||||
expect(mocks.album.removeAsset).not.toHaveBeenCalled();
|
expect(mocks.album.removeAssetsFromAll).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should link photo and video', async () => {
|
it('should link photo and video', async () => {
|
||||||
@ -1356,7 +1356,7 @@ describe(MetadataService.name, () => {
|
|||||||
id: assetStub.livePhotoMotionAsset.id,
|
id: assetStub.livePhotoMotionAsset.id,
|
||||||
visibility: AssetVisibility.HIDDEN,
|
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 () => {
|
it('should notify clients on live photo link', async () => {
|
||||||
|
@ -158,7 +158,7 @@ export class MetadataService extends BaseService {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.assetRepository.update({ id: photoAsset.id, livePhotoVideoId: motionAsset.id }),
|
this.assetRepository.update({ id: photoAsset.id, livePhotoVideoId: motionAsset.id }),
|
||||||
this.assetRepository.update({ id: motionAsset.id, visibility: AssetVisibility.HIDDEN }),
|
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 });
|
await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId });
|
||||||
|
@ -34,6 +34,7 @@ describe('SessionService', () => {
|
|||||||
token: '420',
|
token: '420',
|
||||||
userId: '42',
|
userId: '42',
|
||||||
updateId: 'uuid-v7',
|
updateId: 'uuid-v7',
|
||||||
|
pinExpiresAt: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
mocks.session.delete.mockResolvedValue();
|
mocks.session.delete.mockResolvedValue();
|
||||||
|
@ -156,6 +156,7 @@ describe(SharedLinkService.name, () => {
|
|||||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||||
authStub.admin.user.id,
|
authStub.admin.user.id,
|
||||||
new Set([assetStub.image.id]),
|
new Set([assetStub.image.id]),
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
expect(mocks.sharedLink.create).toHaveBeenCalledWith({
|
expect(mocks.sharedLink.create).toHaveBeenCalledWith({
|
||||||
type: SharedLinkType.INDIVIDUAL,
|
type: SharedLinkType.INDIVIDUAL,
|
||||||
@ -186,6 +187,7 @@ describe(SharedLinkService.name, () => {
|
|||||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||||
authStub.admin.user.id,
|
authStub.admin.user.id,
|
||||||
new Set([assetStub.image.id]),
|
new Set([assetStub.image.id]),
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
expect(mocks.sharedLink.create).toHaveBeenCalledWith({
|
expect(mocks.sharedLink.create).toHaveBeenCalledWith({
|
||||||
type: SharedLinkType.INDIVIDUAL,
|
type: SharedLinkType.INDIVIDUAL,
|
||||||
|
@ -81,7 +81,7 @@ const checkSharedLinkAccess = async (
|
|||||||
|
|
||||||
case Permission.ASSET_SHARE: {
|
case Permission.ASSET_SHARE: {
|
||||||
// TODO: fix this to not use sharedLink.userId for access control
|
// 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: {
|
case Permission.ALBUM_READ: {
|
||||||
@ -119,38 +119,38 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
case Permission.ASSET_READ: {
|
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 isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
|
||||||
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
|
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
|
||||||
return setUnion(isOwner, isAlbum, isPartner);
|
return setUnion(isOwner, isAlbum, isPartner);
|
||||||
}
|
}
|
||||||
|
|
||||||
case Permission.ASSET_SHARE: {
|
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));
|
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
|
||||||
return setUnion(isOwner, isPartner);
|
return setUnion(isOwner, isPartner);
|
||||||
}
|
}
|
||||||
|
|
||||||
case Permission.ASSET_VIEW: {
|
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 isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
|
||||||
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
|
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
|
||||||
return setUnion(isOwner, isAlbum, isPartner);
|
return setUnion(isOwner, isAlbum, isPartner);
|
||||||
}
|
}
|
||||||
|
|
||||||
case Permission.ASSET_DOWNLOAD: {
|
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 isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
|
||||||
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
|
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
|
||||||
return setUnion(isOwner, isAlbum, isPartner);
|
return setUnion(isOwner, isAlbum, isPartner);
|
||||||
}
|
}
|
||||||
|
|
||||||
case Permission.ASSET_UPDATE: {
|
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: {
|
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: {
|
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';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
|
|
||||||
const authUser = {
|
const authUser = {
|
||||||
@ -26,7 +26,7 @@ export const authStub = {
|
|||||||
user: authUser.user1,
|
user: authUser.user1,
|
||||||
session: {
|
session: {
|
||||||
id: 'token-id',
|
id: 'token-id',
|
||||||
} as Session,
|
} as AuthSession,
|
||||||
}),
|
}),
|
||||||
user2: Object.freeze<AuthDto>({
|
user2: Object.freeze<AuthDto>({
|
||||||
user: {
|
user: {
|
||||||
@ -39,7 +39,7 @@ export const authStub = {
|
|||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
id: 'token-id',
|
id: 'token-id',
|
||||||
} as Session,
|
} as AuthSession,
|
||||||
}),
|
}),
|
||||||
adminSharedLink: Object.freeze({
|
adminSharedLink: Object.freeze({
|
||||||
user: authUser.admin,
|
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,
|
isTrashed: false,
|
||||||
libraryId: 'library-id',
|
libraryId: 'library-id',
|
||||||
hasMetadata: true,
|
hasMetadata: true,
|
||||||
|
visibility: AssetVisibility.TIMELINE,
|
||||||
};
|
};
|
||||||
|
|
||||||
const assetResponseWithoutMetadata = {
|
const assetResponseWithoutMetadata = {
|
||||||
|
@ -58,7 +58,7 @@ const authFactory = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
auth.session = { id: session.id };
|
auth.session = { id: session.id, hasElevatedPermission: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sharedLink) {
|
if (sharedLink) {
|
||||||
@ -127,6 +127,7 @@ const sessionFactory = (session: Partial<Session> = {}) => ({
|
|||||||
deviceType: 'mobile',
|
deviceType: 'mobile',
|
||||||
token: 'abc123',
|
token: 'abc123',
|
||||||
userId: newUuid(),
|
userId: newUuid(),
|
||||||
|
pinExpiresAt: newDate(),
|
||||||
...session,
|
...session,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -13,6 +13,8 @@ type ActionMap = {
|
|||||||
[AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto };
|
[AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto };
|
||||||
[AssetAction.UNSTACK]: { assets: AssetResponseDto[] };
|
[AssetAction.UNSTACK]: { assets: AssetResponseDto[] };
|
||||||
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: AssetResponseDto };
|
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: AssetResponseDto };
|
||||||
|
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: AssetResponseDto };
|
||||||
|
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: AssetResponseDto };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Action = {
|
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 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 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 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 ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte';
|
||||||
import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-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';
|
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
|
||||||
@ -27,6 +28,7 @@
|
|||||||
import {
|
import {
|
||||||
AssetJobName,
|
AssetJobName,
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
|
Visibility,
|
||||||
type AlbumResponseDto,
|
type AlbumResponseDto,
|
||||||
type AssetResponseDto,
|
type AssetResponseDto,
|
||||||
type PersonResponseDto,
|
type PersonResponseDto,
|
||||||
@ -91,6 +93,7 @@
|
|||||||
const sharedLink = getSharedLink();
|
const sharedLink = getSharedLink();
|
||||||
let isOwner = $derived($user && asset.ownerId === $user?.id);
|
let isOwner = $derived($user && asset.ownerId === $user?.id);
|
||||||
let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline);
|
let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline);
|
||||||
|
let isLocked = $derived(asset.visibility === Visibility.Locked);
|
||||||
|
|
||||||
// $: showEditorButton =
|
// $: showEditorButton =
|
||||||
// isOwner &&
|
// isOwner &&
|
||||||
@ -112,7 +115,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 overflow-x-auto text-white" data-testid="asset-viewer-navbar-actions">
|
<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} />
|
<ShareAction {asset} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if asset.isOffline}
|
{#if asset.isOffline}
|
||||||
@ -159,17 +162,20 @@
|
|||||||
<DeleteAction {asset} {onAction} {preAction} />
|
<DeleteAction {asset} {onAction} {preAction} />
|
||||||
|
|
||||||
<ButtonContextMenu direction="left" align="top-right" color="opaque" title={$t('more')} icon={mdiDotsVertical}>
|
<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} />
|
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if showDownloadButton}
|
{#if showDownloadButton}
|
||||||
<DownloadAction {asset} menuItem />
|
<DownloadAction {asset} menuItem />
|
||||||
{/if}
|
{/if}
|
||||||
{#if asset.isTrashed}
|
|
||||||
<RestoreAction {asset} {onAction} />
|
{#if !isLocked}
|
||||||
{:else}
|
{#if asset.isTrashed}
|
||||||
<AddToAlbumAction {asset} {onAction} />
|
<RestoreAction {asset} {onAction} />
|
||||||
<AddToAlbumAction {asset} {onAction} shared />
|
{:else}
|
||||||
|
<AddToAlbumAction {asset} {onAction} />
|
||||||
|
<AddToAlbumAction {asset} {onAction} shared />
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
@ -183,21 +189,28 @@
|
|||||||
{#if person}
|
{#if person}
|
||||||
<SetFeaturedPhotoAction {asset} {person} />
|
<SetFeaturedPhotoAction {asset} {person} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if asset.type === AssetTypeEnum.Image}
|
{#if asset.type === AssetTypeEnum.Image && !isLocked}
|
||||||
<SetProfilePictureAction {asset} />
|
<SetProfilePictureAction {asset} />
|
||||||
{/if}
|
{/if}
|
||||||
<ArchiveAction {asset} {onAction} {preAction} />
|
|
||||||
<MenuOption
|
{#if !isLocked}
|
||||||
icon={mdiUpload}
|
<ArchiveAction {asset} {onAction} {preAction} />
|
||||||
onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
|
|
||||||
text={$t('replace_with_upload')}
|
|
||||||
/>
|
|
||||||
{#if !asset.isArchived && !asset.isTrashed}
|
|
||||||
<MenuOption
|
<MenuOption
|
||||||
icon={mdiImageSearch}
|
icon={mdiUpload}
|
||||||
onClick={() => goto(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`)}
|
onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
|
||||||
text={$t('view_in_timeline')}
|
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}
|
{/if}
|
||||||
<hr />
|
<hr />
|
||||||
<MenuOption
|
<MenuOption
|
||||||
|
@ -2,11 +2,12 @@
|
|||||||
import { Card, CardBody, CardHeader, Heading, immichLogo, Logo, VStack } from '@immich/ui';
|
import { Card, CardBody, CardHeader, Heading, immichLogo, Logo, VStack } from '@immich/ui';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title?: string;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
|
withHeader?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { title, children }: Props = $props();
|
let { title, children, withHeader = true }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="min-w-dvw flex min-h-dvh items-center justify-center relative">
|
<section class="min-w-dvw flex min-h-dvh items-center justify-center relative">
|
||||||
@ -18,12 +19,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card color="secondary" class="w-full max-w-lg border m-2">
|
<Card color="secondary" class="w-full max-w-lg border m-2">
|
||||||
<CardHeader class="mt-6">
|
{#if withHeader}
|
||||||
<VStack>
|
<CardHeader class="mt-6">
|
||||||
<Logo variant="icon" size="giant" />
|
<VStack>
|
||||||
<Heading size="large" class="font-semibold" color="primary" tag="h1">{title}</Heading>
|
<Logo variant="icon" size="giant" />
|
||||||
</VStack>
|
<Heading size="large" class="font-semibold" color="primary" tag="h1">{title}</Heading>
|
||||||
</CardHeader>
|
</VStack>
|
||||||
|
</CardHeader>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<CardBody class="p-8">
|
<CardBody class="p-8">
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
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 MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||||
import { getAssetControlContext } from '../asset-select-control-bar.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 DeleteAssetDialog from '../delete-asset-dialog.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onAssetDelete: OnDelete;
|
onAssetDelete: OnDelete;
|
||||||
|
@ -1,17 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
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 { 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 {
|
interface Props {
|
||||||
assetStore: AssetStore;
|
assetStore: AssetStore;
|
||||||
assetInteraction: AssetInteraction;
|
assetInteraction: AssetInteraction;
|
||||||
|
withText?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { assetStore, assetInteraction }: Props = $props();
|
let { assetStore, assetInteraction, withText = false }: Props = $props();
|
||||||
|
|
||||||
const handleSelectAll = async () => {
|
const handleSelectAll = async () => {
|
||||||
await selectAllAssets(assetStore, assetInteraction);
|
await selectAllAssets(assetStore, assetInteraction);
|
||||||
@ -22,8 +24,20 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $isSelectingAllAssets}
|
{#if withText}
|
||||||
<CircleIconButton title={$t('unselect_all')} icon={mdiSelectRemove} onclick={handleCancel} />
|
<Button
|
||||||
|
leadingIcon={$isSelectingAllAssets ? mdiSelectRemove : mdiSelectAll}
|
||||||
|
size="medium"
|
||||||
|
color="secondary"
|
||||||
|
variant="ghost"
|
||||||
|
onclick={$isSelectingAllAssets ? handleCancel : handleSelectAll}
|
||||||
|
>
|
||||||
|
{$isSelectingAllAssets ? $t('unselect_all') : $t('select_all')}
|
||||||
|
</Button>
|
||||||
{:else}
|
{: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}
|
{/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;
|
enableRouting: boolean;
|
||||||
assetStore: AssetStore;
|
assetStore: AssetStore;
|
||||||
assetInteraction: AssetInteraction;
|
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;
|
withStacked?: boolean;
|
||||||
showArchiveIcon?: boolean;
|
showArchiveIcon?: boolean;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
@ -417,7 +423,9 @@
|
|||||||
case AssetAction.TRASH:
|
case AssetAction.TRASH:
|
||||||
case AssetAction.RESTORE:
|
case AssetAction.RESTORE:
|
||||||
case AssetAction.DELETE:
|
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
|
// find the next asset to show or close the viewer
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
(await handleNext()) || (await handlePrevious()) || (await handleClose({ asset: action.asset }));
|
(await handleNext()) || (await handlePrevious()) || (await handleClose({ asset: action.asset }));
|
||||||
@ -445,6 +453,7 @@
|
|||||||
|
|
||||||
case AssetAction.UNSTACK: {
|
case AssetAction.UNSTACK: {
|
||||||
updateUnstackedAssetInTimeline(assetStore, action.assets);
|
updateUnstackedAssetInTimeline(assetStore, action.assets);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -6,9 +6,10 @@
|
|||||||
text: string;
|
text: string;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
src?: string;
|
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');
|
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}"
|
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" />
|
<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>
|
</svelte:element>
|
||||||
|
@ -19,6 +19,8 @@
|
|||||||
mdiImageMultiple,
|
mdiImageMultiple,
|
||||||
mdiImageMultipleOutline,
|
mdiImageMultipleOutline,
|
||||||
mdiLink,
|
mdiLink,
|
||||||
|
mdiLock,
|
||||||
|
mdiLockOutline,
|
||||||
mdiMagnify,
|
mdiMagnify,
|
||||||
mdiMap,
|
mdiMap,
|
||||||
mdiMapOutline,
|
mdiMapOutline,
|
||||||
@ -40,6 +42,7 @@
|
|||||||
let isSharingSelected: boolean = $state(false);
|
let isSharingSelected: boolean = $state(false);
|
||||||
let isTrashSelected: boolean = $state(false);
|
let isTrashSelected: boolean = $state(false);
|
||||||
let isUtilitiesSelected: boolean = $state(false);
|
let isUtilitiesSelected: boolean = $state(false);
|
||||||
|
let isLockedFolderSelected: boolean = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Sidebar ariaLabel={$t('primary')}>
|
<Sidebar ariaLabel={$t('primary')}>
|
||||||
@ -128,6 +131,13 @@
|
|||||||
icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
|
icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
|
||||||
></SideBarLink>
|
></SideBarLink>
|
||||||
|
|
||||||
|
<SideBarLink
|
||||||
|
title={$t('locked_folder')}
|
||||||
|
routeId="/(user)/locked"
|
||||||
|
bind:isSelected={isLockedFolderSelected}
|
||||||
|
icon={isLockedFolderSelected ? mdiLock : mdiLockOutline}
|
||||||
|
></SideBarLink>
|
||||||
|
|
||||||
{#if $featureFlags.trash}
|
{#if $featureFlags.trash}
|
||||||
<SideBarLink
|
<SideBarLink
|
||||||
title={$t('trash')}
|
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">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
label: string;
|
label: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
pinLength?: number;
|
pinLength?: number;
|
||||||
tabindexStart?: 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 pinValues = $state(Array.from({ length: pinLength }).fill(''));
|
||||||
let pinCodeInputElements: HTMLInputElement[] = $state([]);
|
let pinCodeInputElements: HTMLInputElement[] = $state([]);
|
||||||
@ -17,6 +30,12 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (autofocus) {
|
||||||
|
pinCodeInputElements[0]?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const focusNext = (index: number) => {
|
const focusNext = (index: number) => {
|
||||||
pinCodeInputElements[Math.min(index + 1, pinLength - 1)]?.focus();
|
pinCodeInputElements[Math.min(index + 1, pinLength - 1)]?.focus();
|
||||||
};
|
};
|
||||||
@ -48,6 +67,10 @@
|
|||||||
if (value && index < pinLength - 1) {
|
if (value && index < pinLength - 1) {
|
||||||
focusNext(index);
|
focusNext(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (value.length === pinLength) {
|
||||||
|
onFilled?.(value);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent & { currentTarget: EventTarget & HTMLInputElement }) {
|
function handleKeydown(event: KeyboardEvent & { currentTarget: EventTarget & HTMLInputElement }) {
|
||||||
@ -97,13 +120,13 @@
|
|||||||
{#each { length: pinLength } as _, index (index)}
|
{#each { length: pinLength } as _, index (index)}
|
||||||
<input
|
<input
|
||||||
tabindex={tabindexStart + index}
|
tabindex={tabindexStart + index}
|
||||||
type="text"
|
{type}
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
pattern="[0-9]*"
|
pattern="[0-9]*"
|
||||||
maxlength="1"
|
maxlength="1"
|
||||||
bind:this={pinCodeInputElements[index]}
|
bind:this={pinCodeInputElements[index]}
|
||||||
id="pin-code-{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]}
|
bind:value={pinValues[index]}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
oninput={(event) => handleInput(event, index)}
|
oninput={(event) => handleInput(event, index)}
|
||||||
|
@ -1,116 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import PinCodeChangeForm from '$lib/components/user-settings-page/PinCodeChangeForm.svelte';
|
||||||
notificationController,
|
import PinCodeCreateForm from '$lib/components/user-settings-page/PinCodeCreateForm.svelte';
|
||||||
NotificationType,
|
import { getAuthStatus } from '@immich/sdk';
|
||||||
} 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 { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
let hasPinCode = $state(false);
|
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 () => {
|
onMount(async () => {
|
||||||
const authStatus = await getAuthStatus();
|
const { pinCode } = await getAuthStatus();
|
||||||
hasPinCode = authStatus.pinCode;
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="my-4">
|
<section class="my-4">
|
||||||
<div in:fade={{ duration: 200 }}>
|
{#if hasPinCode}
|
||||||
<form autocomplete="off" onsubmit={handleSubmit} class="mt-6">
|
<div in:fade={{ duration: 200 }} class="mt-6">
|
||||||
<div class="flex flex-col gap-6 place-items-center place-content-center">
|
<PinCodeChangeForm />
|
||||||
{#if hasPinCode}
|
</div>
|
||||||
<p class="text-dark">{$t('change_pin_code')}</p>
|
{:else}
|
||||||
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
|
<div in:fade={{ duration: 200 }} class="mt-6">
|
||||||
|
<PinCodeCreateForm onCreated={() => (hasPinCode = true)} />
|
||||||
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
|
</div>
|
||||||
|
{/if}
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
|
@ -10,6 +10,8 @@ export enum AssetAction {
|
|||||||
ADD_TO_ALBUM = 'add-to-album',
|
ADD_TO_ALBUM = 'add-to-album',
|
||||||
UNSTACK = 'unstack',
|
UNSTACK = 'unstack',
|
||||||
KEEP_THIS_DELETE_OTHERS = 'keep-this-delete-others',
|
KEEP_THIS_DELETE_OTHERS = 'keep-this-delete-others',
|
||||||
|
SET_VISIBILITY_LOCKED = 'set-visibility-locked',
|
||||||
|
SET_VISIBILITY_TIMELINE = 'set-visibility-timeline',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AppRoute {
|
export enum AppRoute {
|
||||||
@ -43,12 +45,14 @@ export enum AppRoute {
|
|||||||
AUTH_REGISTER = '/auth/register',
|
AUTH_REGISTER = '/auth/register',
|
||||||
AUTH_CHANGE_PASSWORD = '/auth/change-password',
|
AUTH_CHANGE_PASSWORD = '/auth/change-password',
|
||||||
AUTH_ONBOARDING = '/auth/onboarding',
|
AUTH_ONBOARDING = '/auth/onboarding',
|
||||||
|
AUTH_PIN_PROMPT = '/auth/pin-prompt',
|
||||||
|
|
||||||
UTILITIES = '/utilities',
|
UTILITIES = '/utilities',
|
||||||
DUPLICATES = '/utilities/duplicates',
|
DUPLICATES = '/utilities/duplicates',
|
||||||
|
|
||||||
FOLDERS = '/folders',
|
FOLDERS = '/folders',
|
||||||
TAGS = '/tags',
|
TAGS = '/tags',
|
||||||
|
LOCKED = '/locked',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ProjectionType {
|
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 OnFavorite = (ids: string[], favorite: boolean) => void;
|
||||||
export type OnStack = (result: StackResponse) => void;
|
export type OnStack = (result: StackResponse) => void;
|
||||||
export type OnUnstack = (assets: AssetResponseDto[]) => void;
|
export type OnUnstack = (assets: AssetResponseDto[]) => void;
|
||||||
|
export type OnSetVisibility = (ids: string[]) => void;
|
||||||
|
|
||||||
export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => {
|
export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => {
|
||||||
const $t = get(t);
|
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 FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
|
||||||
import LinkLivePhotoAction from '$lib/components/photos-page/actions/link-live-photo-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 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 StackAction from '$lib/components/photos-page/actions/stack-action.svelte';
|
||||||
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
|
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
|
||||||
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
|
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
|
||||||
@ -75,6 +76,11 @@
|
|||||||
assetStore.updateAssets([still]);
|
assetStore.updateAssets([still]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSetVisibility = (assetIds: string[]) => {
|
||||||
|
assetStore.removeAssets(assetIds);
|
||||||
|
assetInteraction.clearMultiselect();
|
||||||
|
};
|
||||||
|
|
||||||
beforeNavigate(() => {
|
beforeNavigate(() => {
|
||||||
isFaceEditMode.value = false;
|
isFaceEditMode.value = false;
|
||||||
});
|
});
|
||||||
@ -142,6 +148,7 @@
|
|||||||
<TagAction menuItem />
|
<TagAction menuItem />
|
||||||
{/if}
|
{/if}
|
||||||
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
|
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||||
|
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||||
<hr />
|
<hr />
|
||||||
<AssetJobActions />
|
<AssetJobActions />
|
||||||
</ButtonContextMenu>
|
</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 { 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';
|
import { Sync } from 'factory.ts';
|
||||||
|
|
||||||
export const assetFactory = Sync.makeFactory<AssetResponseDto>({
|
export const assetFactory = Sync.makeFactory<AssetResponseDto>({
|
||||||
@ -24,4 +24,5 @@ export const assetFactory = Sync.makeFactory<AssetResponseDto>({
|
|||||||
checksum: Sync.each(() => faker.string.alphanumeric(28)),
|
checksum: Sync.each(() => faker.string.alphanumeric(28)),
|
||||||
isOffline: Sync.each(() => faker.datatype.boolean()),
|
isOffline: Sync.each(() => faker.datatype.boolean()),
|
||||||
hasMetadata: Sync.each(() => faker.datatype.boolean()),
|
hasMetadata: Sync.each(() => faker.datatype.boolean()),
|
||||||
|
visibility: Visibility.Timeline,
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user