feat: bulk person management (#31960)
Co-authored-by: Jason Rasmussen <jason@rasm.me> Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
co-authored by
Jason Rasmussen
Daniel Dietzler
parent
c263b0d4d2
commit
f32a891d40
+15
-8
@@ -557,6 +557,7 @@
|
|||||||
"allow_public_user_to_download": "Allow public user to download",
|
"allow_public_user_to_download": "Allow public user to download",
|
||||||
"allow_public_user_to_upload": "Allow public user to upload",
|
"allow_public_user_to_upload": "Allow public user to upload",
|
||||||
"allowed": "Allowed",
|
"allowed": "Allowed",
|
||||||
|
"also_known_as": "aka",
|
||||||
"alt_text_qr_code": "QR code image",
|
"alt_text_qr_code": "QR code image",
|
||||||
"always_keep": "Always keep",
|
"always_keep": "Always keep",
|
||||||
"always_keep_photos_hint": "Free Up Space will keep all photos on this device.",
|
"always_keep_photos_hint": "Free Up Space will keep all photos on this device.",
|
||||||
@@ -748,7 +749,6 @@
|
|||||||
"client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate import/removal is available only before login",
|
"client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate import/removal is available only before login",
|
||||||
"client_cert_title": "SSL client certificate [EXPERIMENTAL]",
|
"client_cert_title": "SSL client certificate [EXPERIMENTAL]",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"cluster_group": "Cluster group",
|
|
||||||
"cluster_group_description": "People are recognized across the photos of everyone in the group",
|
"cluster_group_description": "People are recognized across the photos of everyone in the group",
|
||||||
"cluster_group_facial_recognition": "Reset facial recognition",
|
"cluster_group_facial_recognition": "Reset facial recognition",
|
||||||
"cluster_group_facial_recognition_prompt": "Resetting facial recognition will delete all people for all users in this group. Already set names and birth dates will be lost. Are you sure you want to continue?",
|
"cluster_group_facial_recognition_prompt": "Resetting facial recognition will delete all people for all users in this group. Already set names and birth dates will be lost. Are you sure you want to continue?",
|
||||||
@@ -791,7 +791,6 @@
|
|||||||
"copied_image_to_clipboard": "Copied image to clipboard.",
|
"copied_image_to_clipboard": "Copied image to clipboard.",
|
||||||
"copied_to_clipboard": "Copied to clipboard!",
|
"copied_to_clipboard": "Copied to clipboard!",
|
||||||
"copy_error": "Copy error",
|
"copy_error": "Copy error",
|
||||||
"copy_from_my_person": "Copy from my person",
|
|
||||||
"copy_image": "Copy Image",
|
"copy_image": "Copy Image",
|
||||||
"copy_json": "Copy JSON",
|
"copy_json": "Copy JSON",
|
||||||
"copy_link": "Copy link",
|
"copy_link": "Copy link",
|
||||||
@@ -1401,8 +1400,10 @@
|
|||||||
"manage_media_access_subtitle": "Allow the Immich app to manage and move media files.",
|
"manage_media_access_subtitle": "Allow the Immich app to manage and move media files.",
|
||||||
"manage_media_access_title": "Media Management Access",
|
"manage_media_access_title": "Media Management Access",
|
||||||
"manage_oauth_account": "Manage OAuth Account",
|
"manage_oauth_account": "Manage OAuth Account",
|
||||||
|
"manage_people": "Manage people",
|
||||||
|
"manage_people_access_description": "Give other users access to your people, allowing them to view or manage them on your behalf.",
|
||||||
"manage_person_access": "Manage access",
|
"manage_person_access": "Manage access",
|
||||||
"manage_person_access_description": "Give other users access to this person, allowing them to view or manage it on your behalf.",
|
"manage_person_access_description": "Give other users access to this person, allowing them to view or manage person information on your behalf.",
|
||||||
"manage_sharing_with_other_users": "Manage sharing with other users",
|
"manage_sharing_with_other_users": "Manage sharing with other users",
|
||||||
"manage_sharing_with_partners": "Manage sharing with partners",
|
"manage_sharing_with_partners": "Manage sharing with partners",
|
||||||
"manage_the_app_settings": "Manage the app settings",
|
"manage_the_app_settings": "Manage the app settings",
|
||||||
@@ -1640,6 +1641,7 @@
|
|||||||
"partner_page_stop_sharing_content": "{partner} will no longer be able to access your photos.",
|
"partner_page_stop_sharing_content": "{partner} will no longer be able to access your photos.",
|
||||||
"partner_sharing": "Partner Sharing",
|
"partner_sharing": "Partner Sharing",
|
||||||
"partners": "Partners",
|
"partners": "Partners",
|
||||||
|
"partners_description": "Share your photos and videos with other users",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"password_does_not_match": "Password does not match",
|
"password_does_not_match": "Password does not match",
|
||||||
"password_required": "Password Required",
|
"password_required": "Password Required",
|
||||||
@@ -1656,6 +1658,7 @@
|
|||||||
"paused": "Paused",
|
"paused": "Paused",
|
||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"people": "People",
|
"people": "People",
|
||||||
|
"people_cluster_group": "People cluster group",
|
||||||
"people_count": "{count, plural, one {# person} other {# people}}",
|
"people_count": "{count, plural, one {# person} other {# people}}",
|
||||||
"people_edits_count": "Edited {count, plural, one {# person} other {# people}}",
|
"people_edits_count": "Edited {count, plural, one {# person} other {# people}}",
|
||||||
"people_feature_description": "Browsing photos and videos grouped by people",
|
"people_feature_description": "Browsing photos and videos grouped by people",
|
||||||
@@ -1674,9 +1677,9 @@
|
|||||||
"person_age_year_months": "1 year, {months, plural, one {# month} other {# months}} old",
|
"person_age_year_months": "1 year, {months, plural, one {# month} other {# months}} old",
|
||||||
"person_age_years": "{years, plural, one {# year} other {# years}} old",
|
"person_age_years": "{years, plural, one {# year} other {# years}} old",
|
||||||
"person_birthdate": "Born on {date}",
|
"person_birthdate": "Born on {date}",
|
||||||
"person_edit_change_for_all_users": "Also change for {people}?",
|
"person_edit_only_change_for_me": "Only change for me",
|
||||||
|
"person_edit_only_change_for_me_description": "Check to only apply changes for your user",
|
||||||
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
|
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
|
||||||
"person_read_access_message": "You only have read access to this person.",
|
|
||||||
"person_recognized": "Person recognized",
|
"person_recognized": "Person recognized",
|
||||||
"person_share_no_users": "Every user in your cluster group already has access to this person.",
|
"person_share_no_users": "Every user in your cluster group already has access to this person.",
|
||||||
"person_update_strategy": "Apply changes to",
|
"person_update_strategy": "Apply changes to",
|
||||||
@@ -1772,7 +1775,6 @@
|
|||||||
"rating_description": "Display the EXIF rating in the info panel",
|
"rating_description": "Display the EXIF rating in the info panel",
|
||||||
"reaction_options": "Reaction options",
|
"reaction_options": "Reaction options",
|
||||||
"read_changelog": "Read Changelog",
|
"read_changelog": "Read Changelog",
|
||||||
"readonly_access": "Read-only access",
|
|
||||||
"readonly_mode_disabled": "Read-only mode disabled",
|
"readonly_mode_disabled": "Read-only mode disabled",
|
||||||
"readonly_mode_enabled": "Read-only mode enabled",
|
"readonly_mode_enabled": "Read-only mode enabled",
|
||||||
"ready_for_upload": "Ready for upload",
|
"ready_for_upload": "Ready for upload",
|
||||||
@@ -2004,12 +2006,16 @@
|
|||||||
"settings_saved": "Settings saved",
|
"settings_saved": "Settings saved",
|
||||||
"setup_pin_code": "Setup a PIN code",
|
"setup_pin_code": "Setup a PIN code",
|
||||||
"share": "Share",
|
"share": "Share",
|
||||||
|
"share_back": "Share back",
|
||||||
"share_dialog_preparing": "Preparing...",
|
"share_dialog_preparing": "Preparing...",
|
||||||
|
"share_everyone": "Share everyone",
|
||||||
|
"share_everyone_description": "Give {user} access to all of your people.",
|
||||||
"share_link": "Share Link",
|
"share_link": "Share Link",
|
||||||
"share_original": "Use original (large)",
|
"share_original": "Use original (large)",
|
||||||
"share_preview": "Use thumbnail (small)",
|
"share_preview": "Use thumbnail (small)",
|
||||||
"share_quality_body": "Press and hold the share button to choose the image quality before you share.",
|
"share_quality_body": "Press and hold the share button to choose the image quality before you share.",
|
||||||
"share_quality_title": "Choose your share quality",
|
"share_quality_title": "Choose your share quality",
|
||||||
|
"share_with": "Share access with",
|
||||||
"shared": "Shared",
|
"shared": "Shared",
|
||||||
"shared_album_activities_input_disable": "Comment is disabled",
|
"shared_album_activities_input_disable": "Comment is disabled",
|
||||||
"shared_album_activity_remove_content": "Do you want to delete this activity?",
|
"shared_album_activity_remove_content": "Do you want to delete this activity?",
|
||||||
@@ -2017,6 +2023,7 @@
|
|||||||
"shared_album_section_people_action_error": "Error leaving/removing from album",
|
"shared_album_section_people_action_error": "Error leaving/removing from album",
|
||||||
"shared_album_section_people_title": "PEOPLE",
|
"shared_album_section_people_title": "PEOPLE",
|
||||||
"shared_by": "Shared by",
|
"shared_by": "Shared by",
|
||||||
|
"shared_by_me": "Shared by me",
|
||||||
"shared_by_user": "Shared by {user}",
|
"shared_by_user": "Shared by {user}",
|
||||||
"shared_by_you": "Shared by you",
|
"shared_by_you": "Shared by you",
|
||||||
"shared_from_partner": "Photos from {partner}",
|
"shared_from_partner": "Photos from {partner}",
|
||||||
@@ -2057,6 +2064,7 @@
|
|||||||
"shared_links_description": "Share photos and videos with a link",
|
"shared_links_description": "Share photos and videos with a link",
|
||||||
"shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}",
|
"shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}",
|
||||||
"shared_with": "Shared with",
|
"shared_with": "Shared with",
|
||||||
|
"shared_with_count": "Shared ({count})",
|
||||||
"shared_with_me": "Shared with me",
|
"shared_with_me": "Shared with me",
|
||||||
"shared_with_partner": "Shared with {partner}",
|
"shared_with_partner": "Shared with {partner}",
|
||||||
"sharing": "Sharing",
|
"sharing": "Sharing",
|
||||||
@@ -2140,6 +2148,7 @@
|
|||||||
"stop_motion_photo": "Stop Motion Photo",
|
"stop_motion_photo": "Stop Motion Photo",
|
||||||
"stop_photo_sharing": "Stop sharing your photos?",
|
"stop_photo_sharing": "Stop sharing your photos?",
|
||||||
"stop_photo_sharing_description": "{partner} will no longer be able to access your photos.",
|
"stop_photo_sharing_description": "{partner} will no longer be able to access your photos.",
|
||||||
|
"stop_sharing_person_with_user": "Stop sharing this person with this user",
|
||||||
"stop_sharing_photos_with_user": "Stop sharing your photos with this user",
|
"stop_sharing_photos_with_user": "Stop sharing your photos with this user",
|
||||||
"storage": "Storage space",
|
"storage": "Storage space",
|
||||||
"storage_label": "Storage label",
|
"storage_label": "Storage label",
|
||||||
@@ -2327,8 +2336,6 @@
|
|||||||
"view_album": "View Album",
|
"view_album": "View Album",
|
||||||
"view_all": "View All",
|
"view_all": "View All",
|
||||||
"view_all_users": "View all users",
|
"view_all_users": "View all users",
|
||||||
"view_and_edit_person_fields": "View and edit person fields for this user.",
|
|
||||||
"view_as": "View as {name}",
|
|
||||||
"view_asset_owners": "View asset owners",
|
"view_asset_owners": "View asset owners",
|
||||||
"view_details": "View Details",
|
"view_details": "View Details",
|
||||||
"view_group": "View group",
|
"view_group": "View group",
|
||||||
|
|||||||
@@ -10228,13 +10228,13 @@
|
|||||||
},
|
},
|
||||||
"put": {
|
"put": {
|
||||||
"description": "Give users access to people",
|
"description": "Give users access to people",
|
||||||
"operationId": "addUsersToPeople",
|
"operationId": "upsertPeopleUsers",
|
||||||
"parameters": [],
|
"parameters": [],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/PersonUsersCreateDto"
|
"$ref": "#/components/schemas/PeopleUsersUpsertDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -10256,7 +10256,7 @@
|
|||||||
"api_key": []
|
"api_key": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"summary": "Give users access to people",
|
"summary": "Upsert user access",
|
||||||
"tags": [
|
"tags": [
|
||||||
"People"
|
"People"
|
||||||
],
|
],
|
||||||
@@ -25133,6 +25133,47 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"PeopleUsersUpsertDto": {
|
||||||
|
"properties": {
|
||||||
|
"personIds": {
|
||||||
|
"description": "Person IDs, required when type is omitted",
|
||||||
|
"items": {
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"$ref": "#/components/schemas/PersonUserRole",
|
||||||
|
"description": "Role that should be applied"
|
||||||
|
},
|
||||||
|
"sharedWithIds": {
|
||||||
|
"description": "User IDs that should be given access to the person",
|
||||||
|
"items": {
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"$ref": "#/components/schemas/PeopleUsersUpsertType"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"role",
|
||||||
|
"sharedWithIds"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"PeopleUsersUpsertType": {
|
||||||
|
"description": "Which people to update when personIds is omitted",
|
||||||
|
"enum": [
|
||||||
|
"everyone"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"Permission": {
|
"Permission": {
|
||||||
"description": "List of permissions",
|
"description": "List of permissions",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -25559,37 +25600,6 @@
|
|||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"PersonUsersCreateDto": {
|
|
||||||
"properties": {
|
|
||||||
"personIds": {
|
|
||||||
"description": "Person IDs, defaults to every person owned by the user",
|
|
||||||
"items": {
|
|
||||||
"format": "uuid",
|
|
||||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
},
|
|
||||||
"role": {
|
|
||||||
"$ref": "#/components/schemas/PersonUserRole",
|
|
||||||
"description": "Role that should be applied"
|
|
||||||
},
|
|
||||||
"sharedWithIds": {
|
|
||||||
"description": "User IDs that should be given access to the person",
|
|
||||||
"items": {
|
|
||||||
"format": "uuid",
|
|
||||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"role",
|
|
||||||
"sharedWithIds"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"PersonUsersDeleteDto": {
|
"PersonUsersDeleteDto": {
|
||||||
"items": {
|
"items": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -2068,13 +2068,14 @@ export type PersonUsersResponseDto = {
|
|||||||
/** User ID of the user that was given access to this person */
|
/** User ID of the user that was given access to this person */
|
||||||
sharedWithId: string;
|
sharedWithId: string;
|
||||||
}[];
|
}[];
|
||||||
export type PersonUsersCreateDto = {
|
export type PeopleUsersUpsertDto = {
|
||||||
/** Person IDs, defaults to every person owned by the user */
|
/** Person IDs, required when type is omitted */
|
||||||
personIds?: string[];
|
personIds?: string[];
|
||||||
/** Role that should be applied */
|
/** Role that should be applied */
|
||||||
role: PersonUserRole;
|
role: PersonUserRole;
|
||||||
/** User IDs that should be given access to the person */
|
/** User IDs that should be given access to the person */
|
||||||
sharedWithIds: string[];
|
sharedWithIds: string[];
|
||||||
|
"type"?: PeopleUsersUpsertType;
|
||||||
};
|
};
|
||||||
export type PersonDeleteDto = {
|
export type PersonDeleteDto = {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
@@ -6205,15 +6206,15 @@ export function getUsersForPeople({ direction, personId, role, sharedById, share
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Give users access to people
|
* Upsert user access
|
||||||
*/
|
*/
|
||||||
export function addUsersToPeople({ personUsersCreateDto }: {
|
export function upsertPeopleUsers({ peopleUsersUpsertDto }: {
|
||||||
personUsersCreateDto: PersonUsersCreateDto;
|
peopleUsersUpsertDto: PeopleUsersUpsertDto;
|
||||||
}, opts?: Oazapfts.RequestOpts) {
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
return oazapfts.ok(oazapfts.fetchText("/people/users", oazapfts.json({
|
return oazapfts.ok(oazapfts.fetchText("/people/users", oazapfts.json({
|
||||||
...opts,
|
...opts,
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: personUsersCreateDto
|
body: peopleUsersUpsertDto
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -8373,6 +8374,9 @@ export enum SharingDirection {
|
|||||||
SharedBy = "shared-by",
|
SharedBy = "shared-by",
|
||||||
SharedWith = "shared-with"
|
SharedWith = "shared-with"
|
||||||
}
|
}
|
||||||
|
export enum PeopleUsersUpsertType {
|
||||||
|
Everyone = "everyone"
|
||||||
|
}
|
||||||
export enum WorkflowType {
|
export enum WorkflowType {
|
||||||
AssetV1 = "AssetV1"
|
AssetV1 = "AssetV1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ import {
|
|||||||
PeopleDeleteDto,
|
PeopleDeleteDto,
|
||||||
PeopleResponseDto,
|
PeopleResponseDto,
|
||||||
PeopleUpdateDto,
|
PeopleUpdateDto,
|
||||||
|
PeopleUsersUpsertDto,
|
||||||
PersonCreateDto,
|
PersonCreateDto,
|
||||||
PersonDeleteDto,
|
PersonDeleteDto,
|
||||||
PersonResponseDto,
|
PersonResponseDto,
|
||||||
PersonSearchDto,
|
PersonSearchDto,
|
||||||
PersonStatisticsResponseDto,
|
PersonStatisticsResponseDto,
|
||||||
PersonUpdateDto,
|
PersonUpdateDto,
|
||||||
PersonUsersCreateDto,
|
|
||||||
PersonUsersDeleteDto,
|
PersonUsersDeleteDto,
|
||||||
PersonUsersResponseDto,
|
PersonUsersResponseDto,
|
||||||
PersonUsersSearchDto,
|
PersonUsersSearchDto,
|
||||||
@@ -240,12 +240,12 @@ export class PersonController {
|
|||||||
@Put('users')
|
@Put('users')
|
||||||
@Authenticated({ permission: Permission.PersonUpdate })
|
@Authenticated({ permission: Permission.PersonUpdate })
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Give users access to people',
|
summary: 'Upsert user access',
|
||||||
description: 'Give users access to people',
|
description: 'Give users access to people',
|
||||||
history: new HistoryBuilder().added('v3.3').stable('v3.3'),
|
history: new HistoryBuilder().added('v3.3').stable('v3.3'),
|
||||||
})
|
})
|
||||||
addUsersToPeople(@Auth() auth: AuthDto, @Body() dto: PersonUsersCreateDto): Promise<void> {
|
upsertPeopleUsers(@Auth() auth: AuthDto, @Body() dto: PeopleUsersUpsertDto): Promise<void> {
|
||||||
return this.service.addUsersToPeople(auth, dto);
|
return this.service.upsertPeopleUsers(auth, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('users')
|
@Delete('users')
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { UserResponseSchema, mapUser } from 'src/dtos/user.dto.js';
|
|||||||
import { SharingDirectionSchema, SourceTypeSchema } from 'src/enum.js';
|
import { SharingDirectionSchema, SourceTypeSchema } from 'src/enum.js';
|
||||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table.js';
|
import { AssetFaceTable } from 'src/schema/tables/asset-face.table.js';
|
||||||
import { asDateString, asDateTimeString } from 'src/utils/date.js';
|
import { asDateString, asDateTimeString } from 'src/utils/date.js';
|
||||||
|
import { hasSomeDefined } from 'src/utils/misc.js';
|
||||||
import { transformFaceBoundingBox } from 'src/utils/transform.js';
|
import { transformFaceBoundingBox } from 'src/utils/transform.js';
|
||||||
import { hexColor, stringToBool, uniqueIds } from 'src/validation.js';
|
import { hexColor, stringToBool, uniqueIds } from 'src/validation.js';
|
||||||
|
|
||||||
@@ -82,6 +83,15 @@ const PersonUserRoleSchema = z
|
|||||||
.describe('Levels of access for managing people resources on behalf of another user.')
|
.describe('Levels of access for managing people resources on behalf of another user.')
|
||||||
.meta({ id: 'PersonUserRole' });
|
.meta({ id: 'PersonUserRole' });
|
||||||
|
|
||||||
|
export enum PeopleUsersUpsertType {
|
||||||
|
Everyone = 'everyone',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PeopleUsersUpsertTypeSchema = z
|
||||||
|
.enum(PeopleUsersUpsertType)
|
||||||
|
.describe('Which people to update when personIds is omitted')
|
||||||
|
.meta({ id: 'PeopleUsersUpsertType' });
|
||||||
|
|
||||||
const PersonOtherResponseSchema = z
|
const PersonOtherResponseSchema = z
|
||||||
.object({
|
.object({
|
||||||
sharedById: z.uuid(),
|
sharedById: z.uuid(),
|
||||||
@@ -227,13 +237,18 @@ const PersonUsersSearchSchema = z
|
|||||||
})
|
})
|
||||||
.meta({ id: 'PersonUsersSearchDto' });
|
.meta({ id: 'PersonUsersSearchDto' });
|
||||||
|
|
||||||
const PersonUsersCreateSchema = z
|
const PeopleUsersUpsertSchema = z
|
||||||
.object({
|
.object({
|
||||||
personIds: uniqueIds.optional().describe('Person IDs, defaults to every person owned by the user'),
|
personIds: uniqueIds.optional().describe('Person IDs, required when type is omitted'),
|
||||||
|
type: PeopleUsersUpsertTypeSchema.optional(),
|
||||||
sharedWithIds: uniqueIds.describe('User IDs that should be given access to the person'),
|
sharedWithIds: uniqueIds.describe('User IDs that should be given access to the person'),
|
||||||
role: PersonUserRoleSchema.describe('Role that should be applied'),
|
role: PersonUserRoleSchema.describe('Role that should be applied'),
|
||||||
})
|
})
|
||||||
.meta({ id: 'PersonUsersCreateDto' });
|
.refine((data) => hasSomeDefined([data.personIds, data.type]), {
|
||||||
|
error: 'Either personIds or type must be provided',
|
||||||
|
path: ['personIds'],
|
||||||
|
})
|
||||||
|
.meta({ id: 'PeopleUsersUpsertDto' });
|
||||||
|
|
||||||
const PersonUsersDeleteSchema = z
|
const PersonUsersDeleteSchema = z
|
||||||
.array(
|
.array(
|
||||||
@@ -252,7 +267,7 @@ export class AssetFaceDeleteDto extends createZodDto(AssetFaceDeleteSchema) {}
|
|||||||
export class PersonStatisticsResponseDto extends createZodDto(PersonStatisticsResponseSchema) {}
|
export class PersonStatisticsResponseDto extends createZodDto(PersonStatisticsResponseSchema) {}
|
||||||
export class PersonUsersResponseDto extends createZodDto(PersonUsersResponseSchema) {}
|
export class PersonUsersResponseDto extends createZodDto(PersonUsersResponseSchema) {}
|
||||||
export class PersonUsersSearchDto extends createZodDto(PersonUsersSearchSchema) {}
|
export class PersonUsersSearchDto extends createZodDto(PersonUsersSearchSchema) {}
|
||||||
export class PersonUsersCreateDto extends createZodDto(PersonUsersCreateSchema) {}
|
export class PeopleUsersUpsertDto extends createZodDto(PeopleUsersUpsertSchema) {}
|
||||||
export class PersonUsersDeleteDto extends createZodDto(PersonUsersDeleteSchema) {}
|
export class PersonUsersDeleteDto extends createZodDto(PersonUsersDeleteSchema) {}
|
||||||
|
|
||||||
const PeopleResponseSchema = z
|
const PeopleResponseSchema = z
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto.js';
|
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto.js';
|
||||||
import { PersonUserRole, mapFaces, mapPerson } from 'src/dtos/person.dto.js';
|
import { PeopleUsersUpsertType, PersonUserRole, mapFaces, mapPerson } from 'src/dtos/person.dto.js';
|
||||||
import { AssetFileType, CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum.js';
|
import { AssetFileType, CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum.js';
|
||||||
import { PersonService } from 'src/services/person.service.js';
|
import { PersonService } from 'src/services/person.service.js';
|
||||||
import { ImmichFileResponse } from 'src/utils/file.js';
|
import { ImmichFileResponse } from 'src/utils/file.js';
|
||||||
@@ -1521,12 +1521,12 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('addUsersToPeople', () => {
|
describe('upsertPeopleUsers', () => {
|
||||||
it('should reject sharing a person with yourself', async () => {
|
it('should reject sharing a person with yourself', async () => {
|
||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.addUsersToPeople(auth, {
|
sut.upsertPeopleUsers(auth, {
|
||||||
personIds: [newUuid()],
|
personIds: [newUuid()],
|
||||||
sharedWithIds: [auth.user.id],
|
sharedWithIds: [auth.user.id],
|
||||||
role: PersonUserRole.Read,
|
role: PersonUserRole.Read,
|
||||||
@@ -1546,7 +1546,7 @@ describe(PersonService.name, () => {
|
|||||||
mocks.user.get.mockResolvedValue(user);
|
mocks.user.get.mockResolvedValue(user);
|
||||||
mocks.clusterGroup.getUsers.mockResolvedValue([user, sharedWith]);
|
mocks.clusterGroup.getUsers.mockResolvedValue([user, sharedWith]);
|
||||||
|
|
||||||
await sut.addUsersToPeople(auth, {
|
await sut.upsertPeopleUsers(auth, {
|
||||||
personIds: [personId],
|
personIds: [personId],
|
||||||
sharedWithIds: [sharedWith.id],
|
sharedWithIds: [sharedWith.id],
|
||||||
role: PersonUserRole.Read,
|
role: PersonUserRole.Read,
|
||||||
@@ -1558,7 +1558,7 @@ describe(PersonService.name, () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should share every person owned by the user when personIds is omitted', async () => {
|
it('should share every person owned by the user when type is everyone', async () => {
|
||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
const user = UserFactory.create({ id: auth.user.id });
|
const user = UserFactory.create({ id: auth.user.id });
|
||||||
const sharedWith = UserFactory.create({ clusterGroupId: user.clusterGroupId });
|
const sharedWith = UserFactory.create({ clusterGroupId: user.clusterGroupId });
|
||||||
@@ -1566,7 +1566,11 @@ describe(PersonService.name, () => {
|
|||||||
mocks.user.get.mockResolvedValue(user);
|
mocks.user.get.mockResolvedValue(user);
|
||||||
mocks.clusterGroup.getUsers.mockResolvedValue([user, sharedWith]);
|
mocks.clusterGroup.getUsers.mockResolvedValue([user, sharedWith]);
|
||||||
|
|
||||||
await sut.addUsersToPeople(auth, { sharedWithIds: [sharedWith.id], role: PersonUserRole.Write });
|
await sut.upsertPeopleUsers(auth, {
|
||||||
|
type: PeopleUsersUpsertType.Everyone,
|
||||||
|
sharedWithIds: [sharedWith.id],
|
||||||
|
role: PersonUserRole.Write,
|
||||||
|
});
|
||||||
|
|
||||||
expect(mocks.access.person.checkAccess).not.toHaveBeenCalled();
|
expect(mocks.access.person.checkAccess).not.toHaveBeenCalled();
|
||||||
expect(mocks.personUser.createAll).not.toHaveBeenCalled();
|
expect(mocks.personUser.createAll).not.toHaveBeenCalled();
|
||||||
@@ -1588,7 +1592,7 @@ describe(PersonService.name, () => {
|
|||||||
mocks.clusterGroup.getUsers.mockResolvedValue([user]);
|
mocks.clusterGroup.getUsers.mockResolvedValue([user]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.addUsersToPeople(auth, { personIds: [personId], sharedWithIds: [outsider.id], role: PersonUserRole.Read }),
|
sut.upsertPeopleUsers(auth, { personIds: [personId], sharedWithIds: [outsider.id], role: PersonUserRole.Read }),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
expect(mocks.personUser.createAll).not.toHaveBeenCalled();
|
expect(mocks.personUser.createAll).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ import {
|
|||||||
PeopleDeleteDto,
|
PeopleDeleteDto,
|
||||||
PeopleResponseDto,
|
PeopleResponseDto,
|
||||||
PeopleUpdateDto,
|
PeopleUpdateDto,
|
||||||
|
PeopleUsersUpsertDto,
|
||||||
PersonCreateDto,
|
PersonCreateDto,
|
||||||
PersonDeleteDto,
|
PersonDeleteDto,
|
||||||
PersonResponseDto,
|
PersonResponseDto,
|
||||||
PersonSearchDto,
|
PersonSearchDto,
|
||||||
PersonStatisticsResponseDto,
|
PersonStatisticsResponseDto,
|
||||||
PersonUpdateDto,
|
PersonUpdateDto,
|
||||||
PersonUsersCreateDto,
|
|
||||||
PersonUsersDeleteDto,
|
PersonUsersDeleteDto,
|
||||||
PersonUsersResponseDto,
|
PersonUsersResponseDto,
|
||||||
PersonUsersSearchDto,
|
PersonUsersSearchDto,
|
||||||
@@ -841,7 +841,7 @@ export class PersonService extends BaseService {
|
|||||||
return mapPersonUsers(sharedUsers);
|
return mapPersonUsers(sharedUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addUsersToPeople(auth: AuthDto, dto: PersonUsersCreateDto) {
|
async upsertPeopleUsers(auth: AuthDto, dto: PeopleUsersUpsertDto) {
|
||||||
if (dto.sharedWithIds.includes(auth.user.id)) {
|
if (dto.sharedWithIds.includes(auth.user.id)) {
|
||||||
throw new BadRequestException('Cannot share a person with yourself');
|
throw new BadRequestException('Cannot share a person with yourself');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1361,7 +1361,7 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('addUsersToPeople', () => {
|
describe('upsertPeopleUsers', () => {
|
||||||
it('should throw error for sharedWith users that are not in the same cluster group', async () => {
|
it('should throw error for sharedWith users that are not in the same cluster group', async () => {
|
||||||
const { sut, ctx } = setup();
|
const { sut, ctx } = setup();
|
||||||
const { user: owner } = await ctx.newUser();
|
const { user: owner } = await ctx.newUser();
|
||||||
@@ -1370,7 +1370,7 @@ describe(PersonService.name, () => {
|
|||||||
const auth = factory.auth({ user: owner });
|
const auth = factory.auth({ user: owner });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.addUsersToPeople(auth, {
|
sut.upsertPeopleUsers(auth, {
|
||||||
personIds: [person.personGroupId],
|
personIds: [person.personGroupId],
|
||||||
sharedWithIds: [user1.id],
|
sharedWithIds: [user1.id],
|
||||||
role: PersonUserRole.Read,
|
role: PersonUserRole.Read,
|
||||||
@@ -1387,7 +1387,7 @@ describe(PersonService.name, () => {
|
|||||||
const { person } = await ctx.newPerson({ ownerId: owner.id });
|
const { person } = await ctx.newPerson({ ownerId: owner.id });
|
||||||
const auth = factory.auth({ user: owner });
|
const auth = factory.auth({ user: owner });
|
||||||
|
|
||||||
await sut.addUsersToPeople(auth, {
|
await sut.upsertPeopleUsers(auth, {
|
||||||
personIds: [person.personGroupId],
|
personIds: [person.personGroupId],
|
||||||
sharedWithIds: [user1.id],
|
sharedWithIds: [user1.id],
|
||||||
role: PersonUserRole.Read,
|
role: PersonUserRole.Read,
|
||||||
@@ -1408,7 +1408,7 @@ describe(PersonService.name, () => {
|
|||||||
const { person } = await ctx.newPerson({ ownerId: owner.id, name: 'Owner name', birthDate: '1990-01-01' });
|
const { person } = await ctx.newPerson({ ownerId: owner.id, name: 'Owner name', birthDate: '1990-01-01' });
|
||||||
await ctx.newPerson({ ownerId: user1.id, personGroupId: person.personGroupId, name: '', birthDate: null });
|
await ctx.newPerson({ ownerId: user1.id, personGroupId: person.personGroupId, name: '', birthDate: null });
|
||||||
|
|
||||||
await sut.addUsersToPeople(factory.auth({ user: owner }), {
|
await sut.upsertPeopleUsers(factory.auth({ user: owner }), {
|
||||||
personIds: [person.personGroupId],
|
personIds: [person.personGroupId],
|
||||||
sharedWithIds: [user1.id],
|
sharedWithIds: [user1.id],
|
||||||
role: PersonUserRole.Read,
|
role: PersonUserRole.Read,
|
||||||
@@ -1431,7 +1431,7 @@ describe(PersonService.name, () => {
|
|||||||
birthDate: '2000-02-02',
|
birthDate: '2000-02-02',
|
||||||
});
|
});
|
||||||
|
|
||||||
await sut.addUsersToPeople(factory.auth({ user: owner }), {
|
await sut.upsertPeopleUsers(factory.auth({ user: owner }), {
|
||||||
personIds: [person.personGroupId],
|
personIds: [person.personGroupId],
|
||||||
sharedWithIds: [user1.id],
|
sharedWithIds: [user1.id],
|
||||||
role: PersonUserRole.Read,
|
role: PersonUserRole.Read,
|
||||||
@@ -1451,7 +1451,7 @@ describe(PersonService.name, () => {
|
|||||||
const { person } = await ctx.newPerson({ ownerId: owner.id });
|
const { person } = await ctx.newPerson({ ownerId: owner.id });
|
||||||
const auth = factory.auth({ user: owner });
|
const auth = factory.auth({ user: owner });
|
||||||
|
|
||||||
await sut.addUsersToPeople(auth, {
|
await sut.upsertPeopleUsers(auth, {
|
||||||
personIds: [person.personGroupId],
|
personIds: [person.personGroupId],
|
||||||
sharedWithIds: [sharedWith.id],
|
sharedWithIds: [sharedWith.id],
|
||||||
role: PersonUserRole.Read,
|
role: PersonUserRole.Read,
|
||||||
@@ -1483,7 +1483,7 @@ describe(PersonService.name, () => {
|
|||||||
const { person: person2 } = await ctx.newPerson({ ownerId: owner.id });
|
const { person: person2 } = await ctx.newPerson({ ownerId: owner.id });
|
||||||
const auth = factory.auth({ user: owner });
|
const auth = factory.auth({ user: owner });
|
||||||
|
|
||||||
await sut.addUsersToPeople(auth, {
|
await sut.upsertPeopleUsers(auth, {
|
||||||
personIds: [person1.personGroupId, person2.personGroupId],
|
personIds: [person1.personGroupId, person2.personGroupId],
|
||||||
sharedWithIds: [user1.id, user2.id],
|
sharedWithIds: [user1.id, user2.id],
|
||||||
role: PersonUserRole.Read,
|
role: PersonUserRole.Read,
|
||||||
@@ -1518,8 +1518,8 @@ describe(PersonService.name, () => {
|
|||||||
const otherAuth = factory.auth({ user: otherOwner });
|
const otherAuth = factory.auth({ user: otherOwner });
|
||||||
const dto = { personIds: [person.personGroupId], sharedWithIds: [sharedWith.id], role: PersonUserRole.Read };
|
const dto = { personIds: [person.personGroupId], sharedWithIds: [sharedWith.id], role: PersonUserRole.Read };
|
||||||
|
|
||||||
await sut.addUsersToPeople(auth, dto);
|
await sut.upsertPeopleUsers(auth, dto);
|
||||||
await sut.addUsersToPeople(otherAuth, dto);
|
await sut.upsertPeopleUsers(otherAuth, dto);
|
||||||
|
|
||||||
await sut.removeUsersFromPeople(auth, [{ personId: person.personGroupId, sharedWithId: sharedWith.id }]);
|
await sut.removeUsersFromPeople(auth, [{ personId: person.personGroupId, sharedWithId: sharedWith.id }]);
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type {
|
|||||||
LibraryResponseDto,
|
LibraryResponseDto,
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
PersonResponseDto,
|
PersonResponseDto,
|
||||||
PersonUserRole,
|
|
||||||
QueueResponseDto,
|
QueueResponseDto,
|
||||||
ReleaseEventV1,
|
ReleaseEventV1,
|
||||||
SharedLinkResponseDto,
|
SharedLinkResponseDto,
|
||||||
@@ -55,7 +54,6 @@ export type Events = {
|
|||||||
|
|
||||||
PersonUpdate: [PersonResponseDto];
|
PersonUpdate: [PersonResponseDto];
|
||||||
PersonShare: [{ personId: string }];
|
PersonShare: [{ personId: string }];
|
||||||
PersonUserUpdate: [{ personId: string; userId: string; role: PersonUserRole }];
|
|
||||||
PersonUserDelete: [{ personId: string; userId: string }];
|
PersonUserDelete: [{ personId: string; userId: string }];
|
||||||
PersonThumbnailReady: [{ id: string }];
|
PersonThumbnailReady: [{ id: string }];
|
||||||
PersonAssetDelete: [{ id: string; assetId: string }];
|
PersonAssetDelete: [{ id: string; assetId: string }];
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal title={$t('cluster_group')} {onClose} size="small">
|
<Modal title={$t('people_cluster_group')} {onClose} size="small">
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
{#await loadUsers()}
|
{#await loadUsers()}
|
||||||
<div class="flex w-full place-content-center place-items-center">
|
<div class="flex w-full place-content-center place-items-center">
|
||||||
|
|||||||
@@ -10,25 +10,21 @@
|
|||||||
title: string;
|
title: string;
|
||||||
selected: string;
|
selected: string;
|
||||||
users: UserResponseDto[];
|
users: UserResponseDto[];
|
||||||
/** show a "no filter" option, which resolves to an empty string */
|
|
||||||
allowEmpty?: boolean;
|
|
||||||
// an empty string means "any user"
|
// an empty string means "any user"
|
||||||
onClose: (userId?: string) => void;
|
onClose: (userId?: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { title, selected, users, allowEmpty = true, onClose }: Props = $props();
|
const { title, selected, users, onClose }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BasicModal {title} onClose={() => onClose()} size="small">
|
<BasicModal {title} onClose={() => onClose()} size="small">
|
||||||
<Stack>
|
<Stack>
|
||||||
{#if allowEmpty}
|
<ListButton selected={selected === ''} onclick={() => onClose('')}>
|
||||||
<ListButton selected={selected === ''} onclick={() => onClose('')}>
|
<div class="flex size-10 shrink-0 items-center justify-center">
|
||||||
<div class="flex size-10 shrink-0 items-center justify-center">
|
<Icon icon={mdiFilterOffOutline} size="24" />
|
||||||
<Icon icon={mdiFilterOffOutline} size="24" />
|
</div>
|
||||||
</div>
|
<Text fontWeight="medium" class="grow text-start">{$t('no_filter')}</Text>
|
||||||
<Text fontWeight="medium" class="grow text-start">{$t('no_filter')}</Text>
|
</ListButton>
|
||||||
</ListButton>
|
|
||||||
{/if}
|
|
||||||
{#each [authManager.user, ...users] as user (user.id)}
|
{#each [authManager.user, ...users] as user (user.id)}
|
||||||
<ListButton selected={selected === user.id} onclick={() => onClose(user.id)}>
|
<ListButton selected={selected === user.id} onclick={() => onClose(user.id)}>
|
||||||
<UserAvatar {user} size="md" />
|
<UserAvatar {user} size="md" />
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
|
||||||
|
import SearchBar from '$lib/elements/SearchBar.svelte';
|
||||||
|
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
|
import { normalizeSearchString } from '$lib/utils/string-utils';
|
||||||
|
import { type PersonResponseDto } from '@immich/sdk';
|
||||||
|
import {
|
||||||
|
ActionButton,
|
||||||
|
Button,
|
||||||
|
HStack,
|
||||||
|
ListButton,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalFooter,
|
||||||
|
Text,
|
||||||
|
type ActionItem,
|
||||||
|
} from '@immich/ui';
|
||||||
|
import { mdiCheckAll, mdiCloseBoxMultipleOutline } from '@mdi/js';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
people: PersonResponseDto[];
|
||||||
|
selectedPeople?: PersonResponseDto[];
|
||||||
|
onClose: (people?: PersonResponseDto[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { people, onClose, selectedPeople = $bindable<PersonResponseDto[]>([]) }: Props = $props();
|
||||||
|
|
||||||
|
let searchName = $state('');
|
||||||
|
|
||||||
|
const selectedIds = $derived(new Set(selectedPeople.map(({ id }) => id)));
|
||||||
|
const filteredPeople = $derived(
|
||||||
|
people.filter(({ name }) => !searchName || normalizeSearchString(name).includes(normalizeSearchString(searchName))),
|
||||||
|
);
|
||||||
|
const namedPeople = $derived(filteredPeople.filter(({ name }) => name));
|
||||||
|
const unnamedPeople = $derived(filteredPeople.filter(({ name }) => !name));
|
||||||
|
|
||||||
|
const selectPerson = (person: PersonResponseDto) => {
|
||||||
|
selectedPeople = selectedIds.has(person.id)
|
||||||
|
? selectedPeople.filter(({ id }) => id !== person.id)
|
||||||
|
: [...selectedPeople, person];
|
||||||
|
};
|
||||||
|
|
||||||
|
const SelectAll: ActionItem = $derived({
|
||||||
|
title: $t('select_all'),
|
||||||
|
icon: mdiCheckAll,
|
||||||
|
$if: () => filteredPeople.some(({ id }) => !selectedIds.has(id)),
|
||||||
|
onAction: () => (selectedPeople = [...selectedPeople, ...filteredPeople.filter(({ id }) => !selectedIds.has(id))]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const UnselectAll: ActionItem = $derived({
|
||||||
|
title: $t('unselect_all'),
|
||||||
|
icon: mdiCloseBoxMultipleOutline,
|
||||||
|
$if: () => filteredPeople.some(({ id }) => selectedIds.has(id)),
|
||||||
|
onAction: () => {
|
||||||
|
const ids = new Set(filteredPeople.map(({ id }) => id));
|
||||||
|
selectedPeople = selectedPeople.filter(({ id }) => !ids.has(id));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal title={$t('manage_people')} {onClose} size="medium">
|
||||||
|
<ModalBody class="flex min-h-0 flex-col">
|
||||||
|
{#if people.length > 0}
|
||||||
|
<div class="flex min-h-0 grow flex-col gap-4">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<ActionButton type="button" shape="round" size="small" action={UnselectAll} />
|
||||||
|
<ActionButton type="button" shape="round" size="small" action={SelectAll} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SearchBar bind:name={searchName} placeholder={$t('search_people')} showLoadingSpinner={false} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-h-0 grow immich-scrollbar flex-col gap-2 overflow-y-auto sm:max-h-120">
|
||||||
|
{#each namedPeople as person (person.id)}
|
||||||
|
<ListButton onclick={() => selectPerson(person)} selected={selectedIds.has(person.id)}>
|
||||||
|
<ImageThumbnail circle url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="4rem" />
|
||||||
|
<Text fontWeight="medium" class="grow truncate text-start">{person.name}</Text>
|
||||||
|
</ListButton>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if unnamedPeople.length > 0}
|
||||||
|
<div class="flex flex-wrap gap-2 p-1">
|
||||||
|
{#each unnamedPeople as person (person.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => selectPerson(person)}
|
||||||
|
class="rounded-full p-1 transition-all hover:bg-subtle {selectedIds.has(person.id)
|
||||||
|
? 'ring-2 ring-primary'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<ImageThumbnail circle url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="4rem" />
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if filteredPeople.length === 0}
|
||||||
|
<Text size="small" color="muted" class="p-2">{$t('no_people_found')}</Text>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<HStack fullWidth>
|
||||||
|
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
|
||||||
|
<Button shape="round" fullWidth onclick={() => onClose(selectedPeople)}>
|
||||||
|
{$t('save')}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</ModalFooter>
|
||||||
|
{:else}
|
||||||
|
<Text color="muted">{$t('no_people_found')}</Text>
|
||||||
|
{/if}
|
||||||
|
</ModalBody>
|
||||||
|
</Modal>
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
|
||||||
|
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
|
||||||
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
import PeopleSelectionModal from '$lib/modals/PeopleSelectionModal.svelte';
|
||||||
|
import { handleBulkUpsert } from '$lib/services/person-user.service';
|
||||||
|
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { getAllPeople, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
|
||||||
|
import {
|
||||||
|
ActionButton,
|
||||||
|
Field,
|
||||||
|
FormModal,
|
||||||
|
LoadingSpinner,
|
||||||
|
modalManager,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
type ActionItem,
|
||||||
|
} from '@immich/ui';
|
||||||
|
import { mdiAccountMultipleOutline, mdiPencilOutline } from '@mdi/js';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
users: UserResponseDto[];
|
||||||
|
people?: PersonResponseDto[];
|
||||||
|
onClose: (saved?: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { users, people, onClose }: Props = $props();
|
||||||
|
|
||||||
|
const user = $derived(users.find(({ id }) => id === selectedUserId) ?? users[0]);
|
||||||
|
const userOptions = users.map(({ id, name }) => ({ label: name, value: id }));
|
||||||
|
|
||||||
|
let selectedUserId = $state(users[0].id);
|
||||||
|
let shareEveryone = $state(false);
|
||||||
|
let serverPeople = $state<PersonResponseDto[]>([]);
|
||||||
|
const added = new SvelteMap<string, PersonResponseDto>();
|
||||||
|
const removed = new SvelteSet<string>();
|
||||||
|
|
||||||
|
const selectedPeople = $derived([...serverPeople.filter(({ id }) => !removed.has(id)), ...added.values()]);
|
||||||
|
const selectedIds = $derived(new Set(selectedPeople.map(({ id }) => id)));
|
||||||
|
|
||||||
|
const canSubmit = $derived(shareEveryone || selectedIds.size > 0 || serverPeople.length > 0);
|
||||||
|
|
||||||
|
const ManagePeople: ActionItem = $derived({
|
||||||
|
title: $t('edit_people'),
|
||||||
|
icon: mdiPencilOutline,
|
||||||
|
onAction: () => handleEditPeople(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadSharedPeople = async (sharedWithId: string) => {
|
||||||
|
if (people) {
|
||||||
|
return people.filter(({ sharedWith }) => sharedWith.some(({ id }) => id === sharedWithId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedPeople: PersonResponseDto[] = [];
|
||||||
|
|
||||||
|
for (let page = 1, hasNextPage = true; hasNextPage; page++) {
|
||||||
|
const result = await getAllPeople({
|
||||||
|
sharedById: authManager.user.id,
|
||||||
|
sharedWithId,
|
||||||
|
withHidden: true,
|
||||||
|
page,
|
||||||
|
size: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
sharedPeople.push(...result.people);
|
||||||
|
hasNextPage = result.hasNextPage ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sharedPeople;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadShares = async (sharedWithId: string) => {
|
||||||
|
added.clear();
|
||||||
|
removed.clear();
|
||||||
|
|
||||||
|
try {
|
||||||
|
serverPeople = await loadSharedPeople(sharedWithId);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.something_went_wrong'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sharedIds = new Set(serverPeople.map(({ id }) => id));
|
||||||
|
const allPeople = await loadAllPeople();
|
||||||
|
const visiblePeople = allPeople.filter(({ isHidden }) => !isHidden);
|
||||||
|
shareEveryone = sharedIds.size > 0 && visiblePeople.every(({ id }) => sharedIds.has(id));
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.something_went_wrong'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectUser = async (id: string) => {
|
||||||
|
selectedUserId = id;
|
||||||
|
await loadShares(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAllPeople = async () => {
|
||||||
|
if (people) {
|
||||||
|
return people;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPeople: PersonResponseDto[] = [];
|
||||||
|
|
||||||
|
for (let page = 1, hasNextPage = true; hasNextPage; page++) {
|
||||||
|
const result = await getAllPeople({ withHidden: false, page, size: 1000 });
|
||||||
|
allPeople.push(...result.people);
|
||||||
|
hasNextPage = result.hasNextPage ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return allPeople;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditPeople = async () => {
|
||||||
|
let allPeople: PersonResponseDto[];
|
||||||
|
|
||||||
|
try {
|
||||||
|
allPeople = await loadAllPeople();
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.something_went_wrong'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedPeople = await modalManager.show(PeopleSelectionModal, {
|
||||||
|
people: allPeople.filter(({ isHidden }) => !isHidden),
|
||||||
|
selectedPeople,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedPeople) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedIds = new Set(updatedPeople.map(({ id }) => id));
|
||||||
|
|
||||||
|
for (const id of selectedIds) {
|
||||||
|
if (updatedIds.has(id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (added.has(id)) {
|
||||||
|
added.delete(id);
|
||||||
|
} else {
|
||||||
|
removed.add(id);
|
||||||
|
shareEveryone = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const person of updatedPeople ?? []) {
|
||||||
|
if (!selectedIds.has(person.id)) {
|
||||||
|
added.set(person.id, person);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
const success = await handleBulkUpsert({
|
||||||
|
sharedWithIds: [selectedUserId],
|
||||||
|
everyone: shareEveryone,
|
||||||
|
personIds: [...added.keys()],
|
||||||
|
removedIds: [...removed],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
onClose(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormModal
|
||||||
|
title={$t('manage_person_access')}
|
||||||
|
submitText={$t('save')}
|
||||||
|
icon={mdiAccountMultipleOutline}
|
||||||
|
size="small"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
{onSubmit}
|
||||||
|
onClose={() => onClose()}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<Text size="small" color="muted">{$t('manage_people_access_description')}</Text>
|
||||||
|
|
||||||
|
{#if users.length > 1}
|
||||||
|
<Field label={$t('share_with')}>
|
||||||
|
<Select value={selectedUserId} options={userOptions} onChange={handleSelectUser} />
|
||||||
|
</Field>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<UserAvatar {user} size="md" />
|
||||||
|
<div class="text-start">
|
||||||
|
<Text fontWeight="medium">{user.name}</Text>
|
||||||
|
<Text size="tiny" color="muted">{user.email}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#await untrack(() => loadShares(selectedUserId))}
|
||||||
|
<div class="flex w-full place-content-center place-items-center">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
{:then}
|
||||||
|
<Field
|
||||||
|
label={$t('share_everyone')}
|
||||||
|
description={$t('share_everyone_description', { values: { user: user.name } })}
|
||||||
|
>
|
||||||
|
<Switch bind:checked={shareEveryone} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{#if !shareEveryone}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Text size="small" fontWeight="medium">
|
||||||
|
{$t('shared_with_count', { values: { count: selectedPeople.length } })}
|
||||||
|
</Text>
|
||||||
|
<ActionButton type="button" color="primary" variant="ghost" action={ManagePeople} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="immich-scrollbar overflow-y-auto sm:max-h-64">
|
||||||
|
{#each selectedPeople as person (person.id)}
|
||||||
|
<div class="flex items-center gap-3 rounded-lg px-2 py-1 hover:bg-subtle">
|
||||||
|
<ImageThumbnail circle url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="2.5rem" />
|
||||||
|
<Text size="small" class="grow truncate">{person.name}</Text>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Text size="small" color="muted" class="p-2">{$t('no_people_found')}</Text>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
</FormModal>
|
||||||
@@ -2,20 +2,17 @@
|
|||||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
|
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
|
||||||
import {
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
getPersonUserActions,
|
import { getPersonUserActions, getPersonUsersActions } from '$lib/services/person-user.service';
|
||||||
getPersonUsersActions,
|
|
||||||
handleUpdatePersonUserRole,
|
|
||||||
} from '$lib/services/person-user.service';
|
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import {
|
import {
|
||||||
|
getClusterGroupUsers,
|
||||||
getUsersForPeople,
|
getUsersForPeople,
|
||||||
PersonUserRole,
|
|
||||||
SharingDirection,
|
SharingDirection,
|
||||||
type PersonResponseDto,
|
type PersonResponseDto,
|
||||||
type PersonUsersResponseDto,
|
type UserResponseDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { ActionButton, BasicModal, Field, HStack, Select, Text } from '@immich/ui';
|
import { ActionButton, BasicModal, HStack, Text, type ActionItem } from '@immich/ui';
|
||||||
import { mdiAccountMultipleOutline } from '@mdi/js';
|
import { mdiAccountMultipleOutline } from '@mdi/js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@@ -27,64 +24,78 @@
|
|||||||
|
|
||||||
let { person, onClose }: Props = $props();
|
let { person, onClose }: Props = $props();
|
||||||
|
|
||||||
let personUsers = $state<PersonUsersResponseDto>([]);
|
let sharedByMe = $state<UserResponseDto[]>([]);
|
||||||
|
let sharedWithMe = $state<UserResponseDto[]>([]);
|
||||||
|
let clusterGroupUsers = $state<UserResponseDto[]>([]);
|
||||||
|
|
||||||
const { AddUsers } = $derived(getPersonUsersActions($t, person, personUsers));
|
const { AddUsers } = $derived(getPersonUsersActions($t, person, [...sharedByMe, ...sharedWithMe], clusterGroupUsers));
|
||||||
|
|
||||||
const refreshPersonUsers = async () => {
|
const refreshPersonUsers = async () => {
|
||||||
try {
|
try {
|
||||||
personUsers = await getUsersForPeople({ personId: person.id, direction: SharingDirection.SharedBy });
|
const [sharedBy, sharedWith] = await Promise.all([
|
||||||
|
getUsersForPeople({ personId: person.id, direction: SharingDirection.SharedBy }),
|
||||||
|
getUsersForPeople({ personId: person.id, direction: SharingDirection.SharedWith }),
|
||||||
|
]);
|
||||||
|
const sharedByMeIds = new Set(sharedBy.map(({ sharedWithId }) => sharedWithId));
|
||||||
|
sharedByMe = sharedBy.map(({ sharedWith }) => sharedWith);
|
||||||
|
sharedWithMe = sharedWith.map(({ sharedBy }) => sharedBy).filter(({ id }) => !sharedByMeIds.has(id));
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.something_went_wrong'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadClusterGroupUsers = async () => {
|
||||||
|
try {
|
||||||
|
clusterGroupUsers = await getClusterGroupUsers({ id: authManager.user.clusterGroupId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.something_went_wrong'));
|
handleError(error, $t('errors.something_went_wrong'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await refreshPersonUsers();
|
await Promise.all([refreshPersonUsers(), loadClusterGroupUsers()]);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<OnEvents
|
{#snippet userCard(user: UserResponseDto, action: ActionItem, type: 'icon' | 'button')}
|
||||||
onPersonShare={refreshPersonUsers}
|
<div class="rounded-2xl border border-gray-200 bg-slate-50 p-4 dark:border-gray-800 dark:bg-gray-900">
|
||||||
onPersonUserUpdate={refreshPersonUsers}
|
<div class="flex items-center justify-between gap-4">
|
||||||
onPersonUserDelete={refreshPersonUsers}
|
<div class="flex min-w-0 items-center gap-4">
|
||||||
/>
|
<UserAvatar {user} size="md" />
|
||||||
|
<div class="min-w-0 text-start">
|
||||||
|
<Text class="truncate">{user.name}</Text>
|
||||||
|
<Text size="small" color="muted" class="truncate">{user.email}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ActionButton {type} shape="round" color={action.color ?? 'secondary'} variant="ghost" size="small" {action} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<BasicModal title={$t('manage_person_access')} size="small" icon={mdiAccountMultipleOutline} {onClose}>
|
<OnEvents onPersonShare={refreshPersonUsers} onPersonUserDelete={refreshPersonUsers} />
|
||||||
|
|
||||||
|
<BasicModal title={$t('manage_person_access')} size="medium" icon={mdiAccountMultipleOutline} {onClose}>
|
||||||
<Text size="small" color="muted" class="mb-4">
|
<Text size="small" color="muted" class="mb-4">
|
||||||
{$t('manage_person_access_description')}
|
{$t('manage_person_access_description')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<HStack fullWidth class="my-2 justify-between">
|
<HStack fullWidth class="my-2 justify-between">
|
||||||
<Text size="medium" fontWeight="semi-bold">{$t('users')}</Text>
|
<Text size="medium" fontWeight="semi-bold">{$t('shared_by_me')}</Text>
|
||||||
<HeaderActionButton action={AddUsers} />
|
<HeaderActionButton action={AddUsers} />
|
||||||
</HStack>
|
</HStack>
|
||||||
<div class="ps-2">
|
|
||||||
{#each personUsers as { sharedWith, role } (sharedWith.id)}
|
<div class="flex flex-col gap-3">
|
||||||
{@const { Delete } = getPersonUserActions($t, person, sharedWith)}
|
{#each sharedByMe as user (user.id)}
|
||||||
<div class="flex items-center justify-between gap-4 py-2">
|
{@render userCard(user, getPersonUserActions($t, person, user).Delete, 'icon')}
|
||||||
<div class="flex w-full items-center justify-between gap-4">
|
|
||||||
<div class="flex flex-row items-center gap-2">
|
|
||||||
<div>
|
|
||||||
<UserAvatar user={sharedWith} size="md" />
|
|
||||||
</div>
|
|
||||||
<Text size="small">{sharedWith.name}</Text>
|
|
||||||
</div>
|
|
||||||
<Field class="w-32">
|
|
||||||
<Select
|
|
||||||
value={role}
|
|
||||||
options={[
|
|
||||||
{ label: 'Read', value: PersonUserRole.Read },
|
|
||||||
{ label: 'Write', value: PersonUserRole.Write },
|
|
||||||
{ label: 'Admin', value: PersonUserRole.Admin },
|
|
||||||
]}
|
|
||||||
onChange={(value) =>
|
|
||||||
handleUpdatePersonUserRole({ personId: person.id, userId: sharedWith.id, role: value })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<ActionButton type="icon" action={Delete} />
|
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if sharedWithMe.length > 0}
|
||||||
|
<Text size="medium" fontWeight="semi-bold" class="mt-4 mb-2">{$t('shared_with_me')}</Text>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#each sharedWithMe as user (user.id)}
|
||||||
|
{@render userCard(user, getPersonUserActions($t, person, user).ShareBack, 'button')}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</BasicModal>
|
</BasicModal>
|
||||||
|
|||||||
@@ -1,190 +1,68 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import PeopleFilterUserPicker from '$lib/modals/PeopleFilterUserPicker.svelte';
|
|
||||||
import { handleUpdatePerson } from '$lib/services/person.service';
|
import { handleUpdatePerson } from '$lib/services/person.service';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { PersonUpdateStrategy, type PersonResponseDto } from '@immich/sdk';
|
||||||
import { PersonUpdateStrategy, PersonUserRole, type PersonResponseDto } from '@immich/sdk';
|
import { Checkbox, DatePicker, Field, FormModal, HelperText, HStack, Input, Label, Stack } from '@immich/ui';
|
||||||
import {
|
import { mdiPencilOutline } from '@mdi/js';
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
DatePicker,
|
|
||||||
Field,
|
|
||||||
FormModal,
|
|
||||||
HelperText,
|
|
||||||
HStack,
|
|
||||||
Input,
|
|
||||||
Label,
|
|
||||||
modalManager,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
} from '@immich/ui';
|
|
||||||
import { mdiAccountMultipleOutline, mdiChevronDown, mdiText } from '@mdi/js';
|
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
person: PersonResponseDto;
|
person: PersonResponseDto;
|
||||||
targetUserId?: string;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { person, targetUserId: initialTargetUserId, onClose }: Props = $props();
|
const { person, onClose }: Props = $props();
|
||||||
|
|
||||||
const candidates = $derived([
|
let name = $state(person.name);
|
||||||
{ name: person.name, birthDate: person.birthDate, sharedById: authManager.user.id },
|
let birthDate = $state(person.birthDate);
|
||||||
...(person.otherPeople ?? []),
|
let onlyForMe = $state(authManager.preferences.people?.updateStrategy !== PersonUpdateStrategy.Everyone);
|
||||||
]);
|
|
||||||
|
|
||||||
const userNames = $derived(
|
|
||||||
new Map([
|
|
||||||
[authManager.user.id, authManager.user.name],
|
|
||||||
...person.sharedBy.map(({ id, name }) => [id, name] as const),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const writableRoles = new Set([PersonUserRole.Write, PersonUserRole.Admin]);
|
|
||||||
|
|
||||||
let targetUserId = $state(initialTargetUserId ?? authManager.user.id);
|
|
||||||
let targetPerson = $state(candidates[0]);
|
|
||||||
let applyToEveryone = $state(authManager.preferences.people?.updateStrategy === PersonUpdateStrategy.Everyone);
|
|
||||||
|
|
||||||
const isWritable = $derived.by(() => {
|
|
||||||
if (!targetUserId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetUserId === authManager.user.id) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sharedBy = person.sharedBy.find((user) => user.id === targetUserId);
|
|
||||||
if (!sharedBy) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return writableRoles.has(sharedBy.role);
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
const response = await handleUpdatePerson({
|
const response = await handleUpdatePerson({
|
||||||
id: person.id,
|
id: person.id,
|
||||||
name: targetPerson.name,
|
name,
|
||||||
birthDate: targetPerson.birthDate,
|
birthDate,
|
||||||
userId: applyToEveryone ? undefined : targetPerson.sharedById,
|
userId: onlyForMe ? authManager.user.id : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response) {
|
if (response) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyFromMine = () => {
|
|
||||||
targetPerson.name = person.name;
|
|
||||||
targetPerson.birthDate = person.birthDate;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onChange = (value: string) => {
|
|
||||||
targetUserId = value;
|
|
||||||
const match = candidates.find((person) => person.sharedById === value);
|
|
||||||
if (match) {
|
|
||||||
targetPerson.name = match.name;
|
|
||||||
targetPerson.birthDate = match.birthDate;
|
|
||||||
targetPerson.sharedById = match.sharedById;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onViewAsAnotherUser = async () => {
|
|
||||||
const candidateIds = new Set(candidates.map(({ sharedById }) => sharedById));
|
|
||||||
const userId = await modalManager.show(PeopleFilterUserPicker, {
|
|
||||||
title: $t('user'),
|
|
||||||
selected: targetUserId,
|
|
||||||
users: person.sharedBy.filter(({ id }) => candidateIds.has(id)),
|
|
||||||
allowEmpty: false,
|
|
||||||
});
|
|
||||||
if (userId) {
|
|
||||||
onChange(userId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
onChange(targetUserId);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FormModal
|
<FormModal
|
||||||
title={$t('person')}
|
title={$t('edit_person')}
|
||||||
size="small"
|
size="small"
|
||||||
icon={mdiText}
|
icon={mdiPencilOutline}
|
||||||
{onClose}
|
{onClose}
|
||||||
{onSubmit}
|
{onSubmit}
|
||||||
submitText={$t('submit')}
|
submitText={$t('submit')}
|
||||||
disabled={!isWritable}
|
|
||||||
>
|
>
|
||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
{#if candidates.length > 1}
|
<Field label={$t('name')}>
|
||||||
<div class="flex flex-col gap-2">
|
<Input bind:value={name} />
|
||||||
<Button
|
|
||||||
color="secondary"
|
|
||||||
size="small"
|
|
||||||
shape="round"
|
|
||||||
trailingIcon={mdiChevronDown}
|
|
||||||
onclick={onViewAsAnotherUser}
|
|
||||||
>
|
|
||||||
{$t('view_as', { values: { name: userNames.get(targetUserId) ?? targetUserId } })}
|
|
||||||
</Button>
|
|
||||||
{#if isWritable}
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
color="secondary"
|
|
||||||
shape="round"
|
|
||||||
variant="ghost"
|
|
||||||
leadingIcon={mdiAccountMultipleOutline}
|
|
||||||
onclick={handleCopyFromMine}
|
|
||||||
>
|
|
||||||
{$t('copy_from_my_person')}
|
|
||||||
</Button>
|
|
||||||
{:else}
|
|
||||||
<Alert shape="rectangle" color="warning" size="small" icon={false} class="mt-4" title={$t('readonly_access')}>
|
|
||||||
<Text size="tiny">{$t('person_read_access_message')}</Text>
|
|
||||||
</Alert>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Field label={$t('name')} disabled={!isWritable}>
|
|
||||||
<Input bind:value={targetPerson.name} />
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label={$t('date_of_birth')} disabled={!isWritable}>
|
<Field label={$t('date_of_birth')}>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
bind:value={
|
bind:value={
|
||||||
() => (targetPerson.birthDate ? DateTime.fromISO(targetPerson.birthDate) : undefined),
|
() => (birthDate ? DateTime.fromISO(birthDate) : undefined), (value) => (birthDate = value?.toISO() ?? null)
|
||||||
(value) => (targetPerson.birthDate = value?.toISO() ?? null)
|
|
||||||
}
|
}
|
||||||
maxDate={DateTime.now()}
|
maxDate={DateTime.now()}
|
||||||
/>
|
/>
|
||||||
<HelperText>{$t('birthdate_set_description')}</HelperText>
|
<HelperText>{$t('birthdate_set_description')}</HelperText>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{#if candidates.length > 1 && isWritable}
|
{#if person.otherPeople.length > 0}
|
||||||
<HStack fullWidth gap={4}>
|
<div>
|
||||||
<Checkbox id="apply-for-all-users-checkbox" color="secondary" size="small" bind:checked={applyToEveryone} />
|
<HStack gap={4}>
|
||||||
<Label
|
<Checkbox id="only-for-me-checkbox" color="secondary" size="small" bind:checked={onlyForMe} />
|
||||||
label={$t('person_edit_change_for_all_users', {
|
<Label label={$t('person_edit_only_change_for_me')} size="small" for="only-for-me-checkbox" />
|
||||||
values: {
|
</HStack>
|
||||||
people: new Intl.ListFormat($locale, { style: 'long' }).format(
|
<HelperText>{$t('person_edit_only_change_for_me_description')}</HelperText>
|
||||||
candidates
|
</div>
|
||||||
.filter(({ sharedById }) => sharedById !== targetUserId)
|
|
||||||
.map(({ sharedById }) => userNames.get(sharedById) ?? sharedById),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
size="small"
|
|
||||||
for="apply-for-all-users-checkbox"
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
{/if}
|
{/if}
|
||||||
</Stack>
|
</Stack>
|
||||||
</FormModal>
|
</FormModal>
|
||||||
|
|||||||
@@ -1,35 +1,50 @@
|
|||||||
import {
|
import {
|
||||||
addUsersToPeople,
|
PeopleUsersUpsertType,
|
||||||
getClusterGroupUsers,
|
|
||||||
PersonUserRole,
|
PersonUserRole,
|
||||||
removeUsersFromPeople,
|
removeUsersFromPeople,
|
||||||
|
upsertPeopleUsers,
|
||||||
type PersonResponseDto,
|
type PersonResponseDto,
|
||||||
type PersonUsersResponseDto,
|
|
||||||
type UserResponseDto,
|
type UserResponseDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||||
import { mdiCheck, mdiPlus, mdiTrashCanOutline } from '@mdi/js';
|
import { mdiAccountMultipleOutline, mdiCheck, mdiClose, mdiPlus, mdiShareOutline } from '@mdi/js';
|
||||||
import { type MessageFormatter } from 'svelte-i18n';
|
import { type MessageFormatter } from 'svelte-i18n';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import AddUsersModal from '$lib/modals/AddUsersModal.svelte';
|
import AddUsersModal from '$lib/modals/AddUsersModal.svelte';
|
||||||
|
import PersonBulkShareModal from '$lib/modals/PersonBulkShareModal.svelte';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
|
|
||||||
|
export const getPeopleUserActions = ($t: MessageFormatter, users: UserResponseDto[], people?: PersonResponseDto[]) => {
|
||||||
|
const ManageAccess: ActionItem = {
|
||||||
|
title: $t('manage_person_access'),
|
||||||
|
icon: mdiAccountMultipleOutline,
|
||||||
|
$if: () => users.length > 0,
|
||||||
|
onAction: () => modalManager.show(PersonBulkShareModal, { users, people }),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ManageAccess };
|
||||||
|
};
|
||||||
|
|
||||||
export const getPersonUsersActions = (
|
export const getPersonUsersActions = (
|
||||||
$t: MessageFormatter,
|
$t: MessageFormatter,
|
||||||
person: PersonResponseDto,
|
person: PersonResponseDto,
|
||||||
personUsers: PersonUsersResponseDto,
|
sharedUsers: UserResponseDto[],
|
||||||
|
clusterGroupUsers: UserResponseDto[],
|
||||||
) => {
|
) => {
|
||||||
|
const excludedUserIds = [authManager.user.id, ...sharedUsers.map(({ id }) => id)];
|
||||||
|
|
||||||
const AddUsers: ActionItem = {
|
const AddUsers: ActionItem = {
|
||||||
title: $t('add_user'),
|
title: $t('add_user'),
|
||||||
icon: mdiPlus,
|
icon: mdiPlus,
|
||||||
color: 'primary',
|
color: 'primary',
|
||||||
|
$if: () => clusterGroupUsers.some(({ id }) => !excludedUserIds.includes(id)),
|
||||||
onAction: () =>
|
onAction: () =>
|
||||||
modalManager.show(AddUsersModal, {
|
modalManager.show(AddUsersModal, {
|
||||||
emptyMessage: $t('person_share_no_users'),
|
emptyMessage: $t('person_share_no_users'),
|
||||||
excludedUserIds: [authManager.user.id, ...personUsers.map(({ sharedWithId }) => sharedWithId)],
|
excludedUserIds,
|
||||||
loadUsers: () => getClusterGroupUsers({ id: authManager.user.clusterGroupId }),
|
loadUsers: () => Promise.resolve(clusterGroupUsers),
|
||||||
onAddUsers: (users: UserResponseDto[]) => handleSharePersonWithUsers(person, users),
|
onAddUsers: (users: UserResponseDto[]) => handleSharePersonWithUsers(person, users),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -39,23 +54,72 @@ export const getPersonUsersActions = (
|
|||||||
|
|
||||||
export const getPersonUserActions = ($t: MessageFormatter, person: PersonResponseDto, user: UserResponseDto) => {
|
export const getPersonUserActions = ($t: MessageFormatter, person: PersonResponseDto, user: UserResponseDto) => {
|
||||||
const Delete: ActionItem = {
|
const Delete: ActionItem = {
|
||||||
title: $t('delete_user'),
|
title: $t('stop_sharing_person_with_user'),
|
||||||
icon: mdiTrashCanOutline,
|
icon: mdiClose,
|
||||||
onAction: () => handleDeletePersonUser(person, user),
|
onAction: () => handleDeletePersonUser(person, user),
|
||||||
};
|
};
|
||||||
|
|
||||||
return { Delete };
|
const ShareBack: ActionItem = {
|
||||||
|
title: $t('share_back'),
|
||||||
|
icon: mdiShareOutline,
|
||||||
|
color: 'primary',
|
||||||
|
onAction: () => handleSharePersonWithUsers(person, [user]),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { Delete, ShareBack };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BulkUpsertOptions = {
|
||||||
|
sharedWithIds: string[];
|
||||||
|
everyone?: boolean;
|
||||||
|
personIds?: string[];
|
||||||
|
removedIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleBulkUpsert = async ({
|
||||||
|
sharedWithIds,
|
||||||
|
everyone = false,
|
||||||
|
personIds = [],
|
||||||
|
removedIds = [],
|
||||||
|
}: BulkUpsertOptions): Promise<boolean> => {
|
||||||
|
const $t = await getFormatter();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (everyone) {
|
||||||
|
await upsertPeopleUsers({
|
||||||
|
peopleUsersUpsertDto: { type: PeopleUsersUpsertType.Everyone, sharedWithIds, role: PersonUserRole.Admin },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (personIds.length > 0) {
|
||||||
|
await upsertPeopleUsers({ peopleUsersUpsertDto: { personIds, sharedWithIds, role: PersonUserRole.Admin } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedIds.length > 0) {
|
||||||
|
await removeUsersFromPeople({
|
||||||
|
personUsersDeleteDto: removedIds.flatMap((personId) =>
|
||||||
|
sharedWithIds.map((sharedWithId) => ({ personId, sharedWithId })),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toastManager.primary({ icon: mdiCheck, title: $t('saved') });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.something_went_wrong'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleSharePersonWithUsers = async (person: PersonResponseDto, users: UserResponseDto[]) => {
|
export const handleSharePersonWithUsers = async (person: PersonResponseDto, users: UserResponseDto[]) => {
|
||||||
const $t = await getFormatter();
|
const $t = await getFormatter();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addUsersToPeople({
|
await upsertPeopleUsers({
|
||||||
personUsersCreateDto: {
|
peopleUsersUpsertDto: {
|
||||||
personIds: [person.id],
|
personIds: [person.id],
|
||||||
sharedWithIds: users.map(({ id }) => id),
|
sharedWithIds: users.map(({ id }) => id),
|
||||||
role: PersonUserRole.Read,
|
role: PersonUserRole.Admin,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
eventManager.emit('PersonShare', { personId: person.id });
|
eventManager.emit('PersonShare', { personId: person.id });
|
||||||
@@ -65,28 +129,6 @@ export const handleSharePersonWithUsers = async (person: PersonResponseDto, user
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleUpdatePersonUserRole = async ({
|
|
||||||
personId,
|
|
||||||
userId,
|
|
||||||
role,
|
|
||||||
}: {
|
|
||||||
personId: string;
|
|
||||||
userId: string;
|
|
||||||
role: PersonUserRole;
|
|
||||||
}) => {
|
|
||||||
const $t = await getFormatter();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await addUsersToPeople({
|
|
||||||
personUsersCreateDto: { personIds: [personId], sharedWithIds: [userId], role },
|
|
||||||
});
|
|
||||||
eventManager.emit('PersonUserUpdate', { personId, userId, role });
|
|
||||||
toastManager.primary({ icon: mdiCheck, title: $t('saved') });
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.something_went_wrong'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const handleDeletePersonUser = async (person: PersonResponseDto, user: UserResponseDto) => {
|
export const handleDeletePersonUser = async (person: PersonResponseDto, user: UserResponseDto) => {
|
||||||
const $t = await getFormatter();
|
const $t = await getFormatter();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
|
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
|
||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
import { QueryParameter, SessionStorageKey } from '$lib/constants';
|
import { QueryParameter, SessionStorageKey } from '$lib/constants';
|
||||||
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import PeopleFilterModal from '$lib/modals/PeopleFilterModal.svelte';
|
import PeopleFilterModal from '$lib/modals/PeopleFilterModal.svelte';
|
||||||
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
|
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
@@ -18,9 +19,17 @@
|
|||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { clearQueryParam } from '$lib/utils/navigation';
|
import { clearQueryParam } from '$lib/utils/navigation';
|
||||||
|
import { getPeopleUserActions } from '$lib/services/person-user.service';
|
||||||
import { handleUpdatePersonName } from '$lib/services/person.service';
|
import { handleUpdatePersonName } from '$lib/services/person.service';
|
||||||
import { getAllPeople, getPerson, searchPerson, type PersonResponseDto } from '@immich/sdk';
|
import {
|
||||||
import { Button, Icon, IconButton, modalManager } from '@immich/ui';
|
getAllPeople,
|
||||||
|
getClusterGroupUsers,
|
||||||
|
getPerson,
|
||||||
|
searchPerson,
|
||||||
|
type PersonResponseDto,
|
||||||
|
type UserResponseDto,
|
||||||
|
} from '@immich/sdk';
|
||||||
|
import { ActionButton, Button, Icon, IconButton, modalManager } from '@immich/ui';
|
||||||
import { mdiAccountOff, mdiEyeOutline, mdiTune } from '@mdi/js';
|
import { mdiAccountOff, mdiEyeOutline, mdiTune } from '@mdi/js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@@ -44,6 +53,17 @@
|
|||||||
let innerHeight = $state(0);
|
let innerHeight = $state(0);
|
||||||
let searchPeopleElement = $state<ReturnType<typeof SearchPeople>>();
|
let searchPeopleElement = $state<ReturnType<typeof SearchPeople>>();
|
||||||
|
|
||||||
|
let clusterGroupUsers: UserResponseDto[] = $state([]);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const users = await getClusterGroupUsers({ id: authManager.user.clusterGroupId });
|
||||||
|
clusterGroupUsers = users.filter(({ id }) => id !== authManager.user.id);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.something_went_wrong'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const getSearchedPeople = $page.url.searchParams.get(QueryParameter.SEARCHED_PEOPLE);
|
const getSearchedPeople = $page.url.searchParams.get(QueryParameter.SEARCHED_PEOPLE);
|
||||||
if (getSearchedPeople) {
|
if (getSearchedPeople) {
|
||||||
@@ -191,6 +211,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let people = $derived(data.people.people);
|
let people = $derived(data.people.people);
|
||||||
|
const { ManageAccess } = $derived(getPeopleUserActions($t, clusterGroupUsers, nextPage ? undefined : people));
|
||||||
|
|
||||||
// hidden people are only shown when explicitly filtering for them
|
// hidden people are only shown when explicitly filtering for them
|
||||||
let visiblePeople = $derived(data.filter.isHidden ? people : people.filter((people) => !people.isHidden));
|
let visiblePeople = $derived(data.filter.isHidden ? people : people.filter((people) => !people.isHidden));
|
||||||
@@ -315,6 +336,7 @@
|
|||||||
color="secondary">{$t('show_and_hide_people')}</Button
|
color="secondary">{$t('show_and_hide_people')}</Button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
|
<ActionButton action={ManageAccess} />
|
||||||
<IconButton
|
<IconButton
|
||||||
shape="round"
|
shape="round"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
|||||||
+3
-21
@@ -37,7 +37,7 @@
|
|||||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { normalizeSearchString } from '$lib/utils/string-utils';
|
import { normalizeSearchString } from '$lib/utils/string-utils';
|
||||||
import { AssetVisibility, PersonUserRole, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk';
|
import { AssetVisibility, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
ActionButton,
|
ActionButton,
|
||||||
CommandPaletteDefaultProvider,
|
CommandPaletteDefaultProvider,
|
||||||
@@ -403,27 +403,9 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#if altItems.length > 0}
|
{#if altItems.length > 0}
|
||||||
{@const parts = new Intl.ListFormat($locale).formatToParts(
|
|
||||||
altItems.map(({ sharedById }) => sharedById),
|
|
||||||
)}
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{#each parts as { type, value } (value)}
|
{$t('also_known_as')}
|
||||||
{#if type === 'element'}
|
{new Intl.ListFormat($locale).format(altItems.map(({ name }) => name))}
|
||||||
{@const altItem = altItems.find(({ sharedById }) => sharedById === value)!}
|
|
||||||
{#if [PersonUserRole.Write, PersonUserRole.Admin].includes(altItem.role)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() =>
|
|
||||||
modalManager.show(PersonEditModal, { person, targetUserId: altItem.sharedById })}
|
|
||||||
class="underline">{altItem.name}</button
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
{altItem.name}
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
{value}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/SettingSwitch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/SettingSwitch.svelte';
|
||||||
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
|
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import ClusterGroupUserSelectionModal from '$lib/modals/ClusterGroupUserSelectionModal.svelte';
|
import ClusterGroupUserSelectionModal from '$lib/modals/ClusterGroupUserSelectionModal.svelte';
|
||||||
import ClusterGroupUsersModal from '$lib/modals/ClusterGroupUsersModal.svelte';
|
import ClusterGroupUsersModal from '$lib/modals/ClusterGroupUsersModal.svelte';
|
||||||
import PartnerSelectionModal from '$lib/modals/PartnerSelectionModal.svelte';
|
import PartnerSelectionModal from '$lib/modals/PartnerSelectionModal.svelte';
|
||||||
|
import { getPeopleUserActions } from '$lib/services/person-user.service';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import {
|
import {
|
||||||
acceptClusterGroupRequest,
|
acceptClusterGroupRequest,
|
||||||
@@ -26,7 +28,7 @@
|
|||||||
type PartnerResponseDto,
|
type PartnerResponseDto,
|
||||||
type UserResponseDto,
|
type UserResponseDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { Button, Card, CardBody, HStack, Icon, IconButton, modalManager, Text } from '@immich/ui';
|
import { ActionButton, Button, Card, CardBody, HStack, Icon, IconButton, modalManager, Text } from '@immich/ui';
|
||||||
import { mdiCheck, mdiClose } from '@mdi/js';
|
import { mdiCheck, mdiClose } from '@mdi/js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@@ -244,158 +246,166 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="my-4">
|
<section class="my-4">
|
||||||
<Text size="large" fontWeight="medium">{$t('cluster_group')}</Text>
|
<div class="flex flex-col sm:ms-4 md:ms-8">
|
||||||
<Text size="small" color="muted">{$t('cluster_group_description')}</Text>
|
<SettingAccordion key="cluster-group" title={$t('people_cluster_group')} subtitle={$t('cluster_group_description')}>
|
||||||
|
<Card class="mt-4">
|
||||||
<Card class="mt-4">
|
<CardBody>
|
||||||
<CardBody>
|
{#each users as user, index (user.id)}
|
||||||
{#each users as user, index (user.id)}
|
{@const { ManageAccess } = getPeopleUserActions($t, [
|
||||||
<div class="flex items-center justify-between gap-4" class:mt-4={index > 0}>
|
user,
|
||||||
<div class="flex items-center gap-4">
|
...users.filter(({ id }) => id !== authManager.user.id && id !== user.id),
|
||||||
<UserAvatar {user} size="md" />
|
])}
|
||||||
<div class="text-start">
|
<div class="flex items-center justify-between gap-4" class:mt-4={index > 0}>
|
||||||
<p class="text-immich-fg dark:text-immich-dark-fg">
|
<div class="flex items-center gap-4">
|
||||||
{user.name}
|
|
||||||
{#if user.id === authManager.user.id}
|
|
||||||
<span class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">({$t('you')})</span>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">{user.email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if user.id === authManager.user.id && canLeave}
|
|
||||||
<Button shape="round" size="small" color="secondary" onclick={() => handleLeave()}>
|
|
||||||
{$t('leave')}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{#if sentRequests.length > 0 || receivedRequests.length > 0}
|
|
||||||
<div class="mt-4">
|
|
||||||
<Text size="small" fontWeight="medium">{$t('pending')}</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card color="secondary" class="mt-2">
|
|
||||||
<CardBody>
|
|
||||||
{#each receivedRequests as request, index (request.id)}
|
|
||||||
<div class="flex items-center justify-between gap-4" class:mt-4={index > 0}>
|
|
||||||
<Text size="small">{$t('request_received_description')}</Text>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button shape="round" size="small" color="secondary" onclick={() => handleViewGroup(request)}>
|
|
||||||
{$t('view_group')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#each sentRequests as request, index (request.id)}
|
|
||||||
{@const user = candidates[request.userId]}
|
|
||||||
<div class="flex items-center justify-between gap-4" class:mt-4={index > 0 || receivedRequests.length > 0}>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
{#if user}
|
|
||||||
<UserAvatar {user} size="md" />
|
<UserAvatar {user} size="md" />
|
||||||
{/if}
|
<div class="text-start">
|
||||||
<div class="text-start">
|
<p class="text-immich-fg dark:text-immich-dark-fg">
|
||||||
<p class="text-immich-fg dark:text-immich-dark-fg">{user?.name ?? request.userId}</p>
|
{user.name}
|
||||||
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">{user?.email ?? ''}</p>
|
{#if user.id === authManager.user.id}
|
||||||
|
<span class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">({$t('you')})</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">{user.email}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if user.id === authManager.user.id && canLeave}
|
||||||
|
<Button shape="round" size="small" color="secondary" onclick={() => handleLeave()}>
|
||||||
|
{$t('leave')}
|
||||||
|
</Button>
|
||||||
|
{:else if user.id !== authManager.user.id}
|
||||||
|
<ActionButton type="icon" variant="filled" action={ManageAccess} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{#if sentRequests.length > 0 || receivedRequests.length > 0}
|
||||||
|
<div class="mt-4">
|
||||||
|
<Text size="small" fontWeight="medium">{$t('pending')}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card color="secondary" class="mt-2">
|
||||||
|
<CardBody>
|
||||||
|
{#each receivedRequests as request, index (request.id)}
|
||||||
|
<div class="flex items-center justify-between gap-4" class:mt-4={index > 0}>
|
||||||
|
<Text size="small">{$t('request_received_description')}</Text>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button shape="round" size="small" color="secondary" onclick={() => handleViewGroup(request)}>
|
||||||
|
{$t('view_group')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#each sentRequests as request, index (request.id)}
|
||||||
|
{@const user = candidates[request.userId]}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-4"
|
||||||
|
class:mt-4={index > 0 || receivedRequests.length > 0}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
{#if user}
|
||||||
|
<UserAvatar {user} size="md" />
|
||||||
|
{/if}
|
||||||
|
<div class="text-start">
|
||||||
|
<p class="text-immich-fg dark:text-immich-dark-fg">{user?.name ?? request.userId}</p>
|
||||||
|
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">{user?.email ?? ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button shape="round" size="small" color="secondary" onclick={() => handleDeleteRequest(request)}>
|
||||||
|
{$t('cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<HStack fullWidth class="mt-5 justify-end">
|
||||||
|
<Button shape="round" size="small" onclick={() => handleRerunFacialRecognition()}
|
||||||
|
>{$t('cluster_group_facial_recognition')}</Button
|
||||||
|
>
|
||||||
|
<Button shape="round" size="small" onclick={() => handleAddUsers()}>{$t('add_user')}</Button>
|
||||||
|
</HStack>
|
||||||
|
</SettingAccordion>
|
||||||
|
|
||||||
|
<SettingAccordion key="partners" title={$t('partners')} subtitle={$t('partners_description')}>
|
||||||
|
{#if partners.length > 0}
|
||||||
|
{#each partners as partner (partner.user.id)}
|
||||||
|
<div class="mt-6 rounded-2xl border border-gray-200 bg-slate-50 p-5 dark:border-gray-800 dark:bg-gray-900">
|
||||||
|
<div class="flex justify-between gap-4 rounded-lg pb-4 transition-all">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<UserAvatar user={partner.user} size="md" />
|
||||||
|
<div class="text-start">
|
||||||
|
<p class="text-immich-fg dark:text-immich-dark-fg">
|
||||||
|
{partner.user.name}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">
|
||||||
|
{partner.user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if partner.sharedByMe}
|
||||||
|
<IconButton
|
||||||
|
shape="round"
|
||||||
|
color="secondary"
|
||||||
|
variant="ghost"
|
||||||
|
onclick={() => handleRemovePartner(partner.user)}
|
||||||
|
icon={mdiClose}
|
||||||
|
size="small"
|
||||||
|
aria-label={$t('stop_sharing_photos_with_user')}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button shape="round" size="small" color="secondary" onclick={() => handleDeleteRequest(request)}>
|
<div class="text-immich-dark-gray dark:text-gray-200">
|
||||||
{$t('cancel')}
|
<!-- I am sharing my assets with this user -->
|
||||||
</Button>
|
{#if partner.sharedByMe}
|
||||||
|
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
|
||||||
|
<Text class="my-4" size="small" fontWeight="medium">
|
||||||
|
{$t('shared_with_partner', { values: { partner: partner.user.name } })}
|
||||||
|
</Text>
|
||||||
|
<Text size="tiny" fontWeight="medium"
|
||||||
|
>{$t('partner_can_access', { values: { partner: partner.user.name } })}</Text
|
||||||
|
>
|
||||||
|
<ul class="text-sm">
|
||||||
|
<li class="mt-2 flex place-items-center gap-2 py-1">
|
||||||
|
<Icon icon={mdiCheck} />
|
||||||
|
{$t('partner_can_access_assets')}
|
||||||
|
</li>
|
||||||
|
<li class="flex place-items-center gap-2 py-1">
|
||||||
|
<Icon icon={mdiCheck} />
|
||||||
|
{$t('partner_can_access_location')}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- this user is sharing assets with me -->
|
||||||
|
{#if partner.sharedWithMe}
|
||||||
|
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
|
||||||
|
<Text class="my-4" size="small" fontWeight="medium">
|
||||||
|
{$t('shared_from_partner', { values: { partner: partner.user.name } })}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<SettingSwitch
|
||||||
|
title={$t('show_in_timeline')}
|
||||||
|
subtitle={$t('show_in_timeline_setting_description')}
|
||||||
|
bind:checked={partner.inTimeline}
|
||||||
|
onToggle={(isChecked) => handleShowOnTimelineChanged(partner, isChecked)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</CardBody>
|
{/if}
|
||||||
</Card>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<HStack fullWidth class="mt-5 justify-end">
|
<div class="mt-5 flex justify-end">
|
||||||
<Button shape="round" size="small" onclick={() => handleRerunFacialRecognition()}
|
<Button shape="round" size="small" onclick={() => handleCreatePartners()}>{$t('add_partner')}</Button>
|
||||||
>{$t('cluster_group_facial_recognition')}</Button
|
|
||||||
>
|
|
||||||
<Button shape="round" size="small" onclick={() => handleAddUsers()}>{$t('add_user')}</Button>
|
|
||||||
</HStack>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="my-4">
|
|
||||||
<Text size="large" fontWeight="medium">{$t('partners')}</Text>
|
|
||||||
|
|
||||||
{#if partners.length > 0}
|
|
||||||
{#each partners as partner (partner.user.id)}
|
|
||||||
<div class="mt-6 rounded-2xl border border-gray-200 bg-slate-50 p-5 dark:border-gray-800 dark:bg-gray-900">
|
|
||||||
<div class="flex justify-between gap-4 rounded-lg pb-4 transition-all">
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<UserAvatar user={partner.user} size="md" />
|
|
||||||
<div class="text-start">
|
|
||||||
<p class="text-immich-fg dark:text-immich-dark-fg">
|
|
||||||
{partner.user.name}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-immich-fg/75 dark:text-immich-dark-fg/75">
|
|
||||||
{partner.user.email}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if partner.sharedByMe}
|
|
||||||
<IconButton
|
|
||||||
shape="round"
|
|
||||||
color="secondary"
|
|
||||||
variant="ghost"
|
|
||||||
onclick={() => handleRemovePartner(partner.user)}
|
|
||||||
icon={mdiClose}
|
|
||||||
size="small"
|
|
||||||
aria-label={$t('stop_sharing_photos_with_user')}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-immich-dark-gray dark:text-gray-200">
|
|
||||||
<!-- I am sharing my assets with this user -->
|
|
||||||
{#if partner.sharedByMe}
|
|
||||||
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
|
|
||||||
<Text class="my-4" size="small" fontWeight="medium">
|
|
||||||
{$t('shared_with_partner', { values: { partner: partner.user.name } })}
|
|
||||||
</Text>
|
|
||||||
<Text size="tiny" fontWeight="medium"
|
|
||||||
>{$t('partner_can_access', { values: { partner: partner.user.name } })}</Text
|
|
||||||
>
|
|
||||||
<ul class="text-sm">
|
|
||||||
<li class="mt-2 flex place-items-center gap-2 py-1">
|
|
||||||
<Icon icon={mdiCheck} />
|
|
||||||
{$t('partner_can_access_assets')}
|
|
||||||
</li>
|
|
||||||
<li class="flex place-items-center gap-2 py-1">
|
|
||||||
<Icon icon={mdiCheck} />
|
|
||||||
{$t('partner_can_access_location')}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- this user is sharing assets with me -->
|
|
||||||
{#if partner.sharedWithMe}
|
|
||||||
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
|
|
||||||
<Text class="my-4" size="small" fontWeight="medium">
|
|
||||||
{$t('shared_from_partner', { values: { partner: partner.user.name } })}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<SettingSwitch
|
|
||||||
title={$t('show_in_timeline')}
|
|
||||||
subtitle={$t('show_in_timeline_setting_description')}
|
|
||||||
bind:checked={partner.inTimeline}
|
|
||||||
onToggle={(isChecked) => handleShowOnTimelineChanged(partner, isChecked)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
</SettingAccordion>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-5 flex justify-end">
|
|
||||||
<Button shape="round" size="small" onclick={() => handleCreatePartners()}>{$t('add_partner')}</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user