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