diff --git a/i18n/en.json b/i18n/en.json index 2673a2548..3092054e9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -557,6 +557,7 @@ "allow_public_user_to_download": "Allow public user to download", "allow_public_user_to_upload": "Allow public user to upload", "allowed": "Allowed", + "also_known_as": "aka", "alt_text_qr_code": "QR code image", "always_keep": "Always keep", "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_title": "SSL client certificate [EXPERIMENTAL]", "close": "Close", - "cluster_group": "Cluster 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_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_to_clipboard": "Copied to clipboard!", "copy_error": "Copy error", - "copy_from_my_person": "Copy from my person", "copy_image": "Copy Image", "copy_json": "Copy JSON", "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_title": "Media Management Access", "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_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_partners": "Manage sharing with partners", "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_sharing": "Partner Sharing", "partners": "Partners", + "partners_description": "Share your photos and videos with other users", "password": "Password", "password_does_not_match": "Password does not match", "password_required": "Password Required", @@ -1656,6 +1658,7 @@ "paused": "Paused", "pending": "Pending", "people": "People", + "people_cluster_group": "People cluster group", "people_count": "{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", @@ -1674,9 +1677,9 @@ "person_age_year_months": "1 year, {months, plural, one {# month} other {# months}} old", "person_age_years": "{years, plural, one {# year} other {# years}} old", "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_read_access_message": "You only have read access to this person.", "person_recognized": "Person recognized", "person_share_no_users": "Every user in your cluster group already has access to this person.", "person_update_strategy": "Apply changes to", @@ -1772,7 +1775,6 @@ "rating_description": "Display the EXIF rating in the info panel", "reaction_options": "Reaction options", "read_changelog": "Read Changelog", - "readonly_access": "Read-only access", "readonly_mode_disabled": "Read-only mode disabled", "readonly_mode_enabled": "Read-only mode enabled", "ready_for_upload": "Ready for upload", @@ -2004,12 +2006,16 @@ "settings_saved": "Settings saved", "setup_pin_code": "Setup a PIN code", "share": "Share", + "share_back": "Share back", "share_dialog_preparing": "Preparing...", + "share_everyone": "Share everyone", + "share_everyone_description": "Give {user} access to all of your people.", "share_link": "Share Link", "share_original": "Use original (large)", "share_preview": "Use thumbnail (small)", "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_with": "Share access with", "shared": "Shared", "shared_album_activities_input_disable": "Comment is disabled", "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_title": "PEOPLE", "shared_by": "Shared by", + "shared_by_me": "Shared by me", "shared_by_user": "Shared by {user}", "shared_by_you": "Shared by you", "shared_from_partner": "Photos from {partner}", @@ -2057,6 +2064,7 @@ "shared_links_description": "Share photos and videos with a link", "shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}", "shared_with": "Shared with", + "shared_with_count": "Shared ({count})", "shared_with_me": "Shared with me", "shared_with_partner": "Shared with {partner}", "sharing": "Sharing", @@ -2140,6 +2148,7 @@ "stop_motion_photo": "Stop Motion Photo", "stop_photo_sharing": "Stop sharing 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", "storage": "Storage space", "storage_label": "Storage label", @@ -2327,8 +2336,6 @@ "view_album": "View Album", "view_all": "View All", "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_details": "View Details", "view_group": "View group", diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index ae7a17457..a8578d8b7 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10228,13 +10228,13 @@ }, "put": { "description": "Give users access to people", - "operationId": "addUsersToPeople", + "operationId": "upsertPeopleUsers", "parameters": [], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PersonUsersCreateDto" + "$ref": "#/components/schemas/PeopleUsersUpsertDto" } } }, @@ -10256,7 +10256,7 @@ "api_key": [] } ], - "summary": "Give users access to people", + "summary": "Upsert user access", "tags": [ "People" ], @@ -25133,6 +25133,47 @@ ], "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": { "description": "List of permissions", "enum": [ @@ -25559,37 +25600,6 @@ ], "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": { "items": { "properties": { diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 8205e3382..7c028ceef 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -2068,13 +2068,14 @@ export type PersonUsersResponseDto = { /** User ID of the user that was given access to this person */ sharedWithId: string; }[]; -export type PersonUsersCreateDto = { - /** Person IDs, defaults to every person owned by the user */ +export type PeopleUsersUpsertDto = { + /** Person IDs, required when type is omitted */ personIds?: string[]; /** Role that should be applied */ role: PersonUserRole; /** User IDs that should be given access to the person */ sharedWithIds: string[]; + "type"?: PeopleUsersUpsertType; }; export type PersonDeleteDto = { 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 }: { - personUsersCreateDto: PersonUsersCreateDto; +export function upsertPeopleUsers({ peopleUsersUpsertDto }: { + peopleUsersUpsertDto: PeopleUsersUpsertDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/people/users", oazapfts.json({ ...opts, method: "PUT", - body: personUsersCreateDto + body: peopleUsersUpsertDto }))); } /** @@ -8373,6 +8374,9 @@ export enum SharingDirection { SharedBy = "shared-by", SharedWith = "shared-with" } +export enum PeopleUsersUpsertType { + Everyone = "everyone" +} export enum WorkflowType { AssetV1 = "AssetV1" } diff --git a/server/src/controllers/person.controller.ts b/server/src/controllers/person.controller.ts index ea125e429..a1ba336a1 100644 --- a/server/src/controllers/person.controller.ts +++ b/server/src/controllers/person.controller.ts @@ -24,13 +24,13 @@ import { PeopleDeleteDto, PeopleResponseDto, PeopleUpdateDto, + PeopleUsersUpsertDto, PersonCreateDto, PersonDeleteDto, PersonResponseDto, PersonSearchDto, PersonStatisticsResponseDto, PersonUpdateDto, - PersonUsersCreateDto, PersonUsersDeleteDto, PersonUsersResponseDto, PersonUsersSearchDto, @@ -240,12 +240,12 @@ export class PersonController { @Put('users') @Authenticated({ permission: Permission.PersonUpdate }) @Endpoint({ - summary: 'Give users access to people', + summary: 'Upsert user access', description: 'Give users access to people', history: new HistoryBuilder().added('v3.3').stable('v3.3'), }) - addUsersToPeople(@Auth() auth: AuthDto, @Body() dto: PersonUsersCreateDto): Promise { - return this.service.addUsersToPeople(auth, dto); + upsertPeopleUsers(@Auth() auth: AuthDto, @Body() dto: PeopleUsersUpsertDto): Promise { + return this.service.upsertPeopleUsers(auth, dto); } @Delete('users') diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index b8ad428e1..909ee064c 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -10,6 +10,7 @@ import { UserResponseSchema, mapUser } from 'src/dtos/user.dto.js'; import { SharingDirectionSchema, SourceTypeSchema } from 'src/enum.js'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table.js'; import { asDateString, asDateTimeString } from 'src/utils/date.js'; +import { hasSomeDefined } from 'src/utils/misc.js'; import { transformFaceBoundingBox } from 'src/utils/transform.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.') .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 .object({ sharedById: z.uuid(), @@ -227,13 +237,18 @@ const PersonUsersSearchSchema = z }) .meta({ id: 'PersonUsersSearchDto' }); -const PersonUsersCreateSchema = z +const PeopleUsersUpsertSchema = z .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'), 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 .array( @@ -252,7 +267,7 @@ export class AssetFaceDeleteDto extends createZodDto(AssetFaceDeleteSchema) {} export class PersonStatisticsResponseDto extends createZodDto(PersonStatisticsResponseSchema) {} export class PersonUsersResponseDto extends createZodDto(PersonUsersResponseSchema) {} export class PersonUsersSearchDto extends createZodDto(PersonUsersSearchSchema) {} -export class PersonUsersCreateDto extends createZodDto(PersonUsersCreateSchema) {} +export class PeopleUsersUpsertDto extends createZodDto(PeopleUsersUpsertSchema) {} export class PersonUsersDeleteDto extends createZodDto(PersonUsersDeleteSchema) {} const PeopleResponseSchema = z diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 8b83f7ee3..ae8d35f5d 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -1,6 +1,6 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; 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 { PersonService } from 'src/services/person.service.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 () => { const auth = AuthFactory.create(); await expect( - sut.addUsersToPeople(auth, { + sut.upsertPeopleUsers(auth, { personIds: [newUuid()], sharedWithIds: [auth.user.id], role: PersonUserRole.Read, @@ -1546,7 +1546,7 @@ describe(PersonService.name, () => { mocks.user.get.mockResolvedValue(user); mocks.clusterGroup.getUsers.mockResolvedValue([user, sharedWith]); - await sut.addUsersToPeople(auth, { + await sut.upsertPeopleUsers(auth, { personIds: [personId], sharedWithIds: [sharedWith.id], 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 user = UserFactory.create({ id: auth.user.id }); const sharedWith = UserFactory.create({ clusterGroupId: user.clusterGroupId }); @@ -1566,7 +1566,11 @@ describe(PersonService.name, () => { mocks.user.get.mockResolvedValue(user); 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.personUser.createAll).not.toHaveBeenCalled(); @@ -1588,7 +1592,7 @@ describe(PersonService.name, () => { mocks.clusterGroup.getUsers.mockResolvedValue([user]); 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); expect(mocks.personUser.createAll).not.toHaveBeenCalled(); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 9e5f1a2de..e5ea0cce5 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -15,13 +15,13 @@ import { PeopleDeleteDto, PeopleResponseDto, PeopleUpdateDto, + PeopleUsersUpsertDto, PersonCreateDto, PersonDeleteDto, PersonResponseDto, PersonSearchDto, PersonStatisticsResponseDto, PersonUpdateDto, - PersonUsersCreateDto, PersonUsersDeleteDto, PersonUsersResponseDto, PersonUsersSearchDto, @@ -841,7 +841,7 @@ export class PersonService extends BaseService { return mapPersonUsers(sharedUsers); } - async addUsersToPeople(auth: AuthDto, dto: PersonUsersCreateDto) { + async upsertPeopleUsers(auth: AuthDto, dto: PeopleUsersUpsertDto) { if (dto.sharedWithIds.includes(auth.user.id)) { throw new BadRequestException('Cannot share a person with yourself'); } diff --git a/server/test/medium/specs/services/person.service.spec.ts b/server/test/medium/specs/services/person.service.spec.ts index 212b72548..2e2377981 100644 --- a/server/test/medium/specs/services/person.service.spec.ts +++ b/server/test/medium/specs/services/person.service.spec.ts @@ -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 () => { const { sut, ctx } = setup(); const { user: owner } = await ctx.newUser(); @@ -1370,7 +1370,7 @@ describe(PersonService.name, () => { const auth = factory.auth({ user: owner }); await expect( - sut.addUsersToPeople(auth, { + sut.upsertPeopleUsers(auth, { personIds: [person.personGroupId], sharedWithIds: [user1.id], role: PersonUserRole.Read, @@ -1387,7 +1387,7 @@ describe(PersonService.name, () => { const { person } = await ctx.newPerson({ ownerId: owner.id }); const auth = factory.auth({ user: owner }); - await sut.addUsersToPeople(auth, { + await sut.upsertPeopleUsers(auth, { personIds: [person.personGroupId], sharedWithIds: [user1.id], 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' }); 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], sharedWithIds: [user1.id], role: PersonUserRole.Read, @@ -1431,7 +1431,7 @@ describe(PersonService.name, () => { birthDate: '2000-02-02', }); - await sut.addUsersToPeople(factory.auth({ user: owner }), { + await sut.upsertPeopleUsers(factory.auth({ user: owner }), { personIds: [person.personGroupId], sharedWithIds: [user1.id], role: PersonUserRole.Read, @@ -1451,7 +1451,7 @@ describe(PersonService.name, () => { const { person } = await ctx.newPerson({ ownerId: owner.id }); const auth = factory.auth({ user: owner }); - await sut.addUsersToPeople(auth, { + await sut.upsertPeopleUsers(auth, { personIds: [person.personGroupId], sharedWithIds: [sharedWith.id], role: PersonUserRole.Read, @@ -1483,7 +1483,7 @@ describe(PersonService.name, () => { const { person: person2 } = await ctx.newPerson({ ownerId: owner.id }); const auth = factory.auth({ user: owner }); - await sut.addUsersToPeople(auth, { + await sut.upsertPeopleUsers(auth, { personIds: [person1.personGroupId, person2.personGroupId], sharedWithIds: [user1.id, user2.id], role: PersonUserRole.Read, @@ -1518,8 +1518,8 @@ describe(PersonService.name, () => { const otherAuth = factory.auth({ user: otherOwner }); const dto = { personIds: [person.personGroupId], sharedWithIds: [sharedWith.id], role: PersonUserRole.Read }; - await sut.addUsersToPeople(auth, dto); - await sut.addUsersToPeople(otherAuth, dto); + await sut.upsertPeopleUsers(auth, dto); + await sut.upsertPeopleUsers(otherAuth, dto); await sut.removeUsersFromPeople(auth, [{ personId: person.personGroupId, sharedWithId: sharedWith.id }]); diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 2caf78e55..5295d876d 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -8,7 +8,6 @@ import type { LibraryResponseDto, LoginResponseDto, PersonResponseDto, - PersonUserRole, QueueResponseDto, ReleaseEventV1, SharedLinkResponseDto, @@ -55,7 +54,6 @@ export type Events = { PersonUpdate: [PersonResponseDto]; PersonShare: [{ personId: string }]; - PersonUserUpdate: [{ personId: string; userId: string; role: PersonUserRole }]; PersonUserDelete: [{ personId: string; userId: string }]; PersonThumbnailReady: [{ id: string }]; PersonAssetDelete: [{ id: string; assetId: string }]; diff --git a/web/src/lib/modals/ClusterGroupUsersModal.svelte b/web/src/lib/modals/ClusterGroupUsersModal.svelte index 2bfb61257..21a07bdc9 100644 --- a/web/src/lib/modals/ClusterGroupUsersModal.svelte +++ b/web/src/lib/modals/ClusterGroupUsersModal.svelte @@ -18,7 +18,7 @@ }; - + {#await loadUsers()}
diff --git a/web/src/lib/modals/PeopleFilterUserPicker.svelte b/web/src/lib/modals/PeopleFilterUserPicker.svelte index 668c8d48a..a0a171579 100644 --- a/web/src/lib/modals/PeopleFilterUserPicker.svelte +++ b/web/src/lib/modals/PeopleFilterUserPicker.svelte @@ -10,25 +10,21 @@ title: string; selected: string; users: UserResponseDto[]; - /** show a "no filter" option, which resolves to an empty string */ - allowEmpty?: boolean; // an empty string means "any user" onClose: (userId?: string) => void; }; - const { title, selected, users, allowEmpty = true, onClose }: Props = $props(); + const { title, selected, users, onClose }: Props = $props(); onClose()} size="small"> - {#if allowEmpty} - onClose('')}> -
- -
- {$t('no_filter')} -
- {/if} + onClose('')}> +
+ +
+ {$t('no_filter')} +
{#each [authManager.user, ...users] as user (user.id)} onClose(user.id)}> diff --git a/web/src/lib/modals/PeopleSelectionModal.svelte b/web/src/lib/modals/PeopleSelectionModal.svelte new file mode 100644 index 000000000..1e94525e5 --- /dev/null +++ b/web/src/lib/modals/PeopleSelectionModal.svelte @@ -0,0 +1,117 @@ + + + + + {#if people.length > 0} +
+
+ + +
+ +
+ +
+ +
+ {#each namedPeople as person (person.id)} + selectPerson(person)} selected={selectedIds.has(person.id)}> + + {person.name} + + {/each} + + {#if unnamedPeople.length > 0} +
+ {#each unnamedPeople as person (person.id)} + + {/each} +
+ {/if} + + {#if filteredPeople.length === 0} + {$t('no_people_found')} + {/if} +
+
+ + + + + + + + {:else} + {$t('no_people_found')} + {/if} +
+
diff --git a/web/src/lib/modals/PersonBulkShareModal.svelte b/web/src/lib/modals/PersonBulkShareModal.svelte new file mode 100644 index 000000000..af2081711 --- /dev/null +++ b/web/src/lib/modals/PersonBulkShareModal.svelte @@ -0,0 +1,233 @@ + + + onClose()} +> +
+ {$t('manage_people_access_description')} + + {#if users.length > 1} + + - handleUpdatePersonUserRole({ personId: person.id, userId: sharedWith.id, role: value })} - /> - -
- -
+ +
+ {#each sharedByMe as user (user.id)} + {@render userCard(user, getPersonUserActions($t, person, user).Delete, 'icon')} {/each}
+ + {#if sharedWithMe.length > 0} + {$t('shared_with_me')} +
+ {#each sharedWithMe as user (user.id)} + {@render userCard(user, getPersonUserActions($t, person, user).ShareBack, 'button')} + {/each} +
+ {/if} diff --git a/web/src/lib/modals/PersonEditModal.svelte b/web/src/lib/modals/PersonEditModal.svelte index c1d9b98d8..8d120b3bc 100644 --- a/web/src/lib/modals/PersonEditModal.svelte +++ b/web/src/lib/modals/PersonEditModal.svelte @@ -1,190 +1,68 @@ - {#if candidates.length > 1} -
- - {#if isWritable} - - {:else} - - {$t('person_read_access_message')} - - {/if} -
- {/if} - - - + + - + (targetPerson.birthDate ? DateTime.fromISO(targetPerson.birthDate) : undefined), - (value) => (targetPerson.birthDate = value?.toISO() ?? null) + () => (birthDate ? DateTime.fromISO(birthDate) : undefined), (value) => (birthDate = value?.toISO() ?? null) } maxDate={DateTime.now()} /> {$t('birthdate_set_description')} - {#if candidates.length > 1 && isWritable} - - - + {#if person.otherPeople.length > 0} +
+ + + + {$t('person_edit_only_change_for_me_description')} +
{/if}
diff --git a/web/src/lib/services/person-user.service.ts b/web/src/lib/services/person-user.service.ts index 39bc29a82..b185cc48e 100644 --- a/web/src/lib/services/person-user.service.ts +++ b/web/src/lib/services/person-user.service.ts @@ -1,35 +1,50 @@ import { - addUsersToPeople, - getClusterGroupUsers, + PeopleUsersUpsertType, PersonUserRole, removeUsersFromPeople, + upsertPeopleUsers, type PersonResponseDto, - type PersonUsersResponseDto, type UserResponseDto, } from '@immich/sdk'; 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 { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import AddUsersModal from '$lib/modals/AddUsersModal.svelte'; +import PersonBulkShareModal from '$lib/modals/PersonBulkShareModal.svelte'; import { handleError } from '$lib/utils/handle-error'; 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 = ( $t: MessageFormatter, person: PersonResponseDto, - personUsers: PersonUsersResponseDto, + sharedUsers: UserResponseDto[], + clusterGroupUsers: UserResponseDto[], ) => { + const excludedUserIds = [authManager.user.id, ...sharedUsers.map(({ id }) => id)]; + const AddUsers: ActionItem = { title: $t('add_user'), icon: mdiPlus, color: 'primary', + $if: () => clusterGroupUsers.some(({ id }) => !excludedUserIds.includes(id)), onAction: () => modalManager.show(AddUsersModal, { emptyMessage: $t('person_share_no_users'), - excludedUserIds: [authManager.user.id, ...personUsers.map(({ sharedWithId }) => sharedWithId)], - loadUsers: () => getClusterGroupUsers({ id: authManager.user.clusterGroupId }), + excludedUserIds, + loadUsers: () => Promise.resolve(clusterGroupUsers), onAddUsers: (users: UserResponseDto[]) => handleSharePersonWithUsers(person, users), }), }; @@ -39,23 +54,72 @@ export const getPersonUsersActions = ( export const getPersonUserActions = ($t: MessageFormatter, person: PersonResponseDto, user: UserResponseDto) => { const Delete: ActionItem = { - title: $t('delete_user'), - icon: mdiTrashCanOutline, + title: $t('stop_sharing_person_with_user'), + icon: mdiClose, 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 => { + 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[]) => { const $t = await getFormatter(); try { - await addUsersToPeople({ - personUsersCreateDto: { + await upsertPeopleUsers({ + peopleUsersUpsertDto: { personIds: [person.id], sharedWithIds: users.map(({ id }) => id), - role: PersonUserRole.Read, + role: PersonUserRole.Admin, }, }); 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) => { const $t = await getFormatter(); diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index 948e12ff2..2af5459a7 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -9,6 +9,7 @@ import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte'; import OnEvents from '$lib/components/OnEvents.svelte'; import { QueryParameter, SessionStorageKey } from '$lib/constants'; + import { authManager } from '$lib/managers/auth-manager.svelte'; import PeopleFilterModal from '$lib/modals/PeopleFilterModal.svelte'; import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte'; import { Route } from '$lib/route'; @@ -18,9 +19,17 @@ import { handlePromiseError } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; import { clearQueryParam } from '$lib/utils/navigation'; + import { getPeopleUserActions } from '$lib/services/person-user.service'; import { handleUpdatePersonName } from '$lib/services/person.service'; - import { getAllPeople, getPerson, searchPerson, type PersonResponseDto } from '@immich/sdk'; - import { Button, Icon, IconButton, modalManager } from '@immich/ui'; + import { + 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 { onMount } from 'svelte'; import { t } from 'svelte-i18n'; @@ -44,6 +53,17 @@ let innerHeight = $state(0); let searchPeopleElement = $state>(); + 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(() => { const getSearchedPeople = $page.url.searchParams.get(QueryParameter.SEARCHED_PEOPLE); if (getSearchedPeople) { @@ -191,6 +211,7 @@ }; 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 let visiblePeople = $derived(data.filter.isHidden ? people : people.filter((people) => !people.isHidden)); @@ -315,6 +336,7 @@ color="secondary">{$t('show_and_hide_people')} {/if} + {#if altItems.length > 0} - {@const parts = new Intl.ListFormat($locale).formatToParts( - altItems.map(({ sharedById }) => sharedById), - )}

- {#each parts as { type, value } (value)} - {#if type === 'element'} - {@const altItem = altItems.find(({ sharedById }) => sharedById === value)!} - {#if [PersonUserRole.Write, PersonUserRole.Admin].includes(altItem.role)} - - {:else} - {altItem.name} - {/if} - {:else} - {value} - {/if} - {/each} + {$t('also_known_as')} + {new Intl.ListFormat($locale).format(altItems.map(({ name }) => name))}

{/if}

diff --git a/web/src/routes/(user)/user-settings/SharingSettings.svelte b/web/src/routes/(user)/user-settings/SharingSettings.svelte index 2929c1daf..143ed3440 100644 --- a/web/src/routes/(user)/user-settings/SharingSettings.svelte +++ b/web/src/routes/(user)/user-settings/SharingSettings.svelte @@ -1,10 +1,12 @@

- {$t('cluster_group')} - {$t('cluster_group_description')} - - - - {#each users as user, index (user.id)} -
0}> -
- -
-

- {user.name} - {#if user.id === authManager.user.id} - ({$t('you')}) - {/if} -

-

{user.email}

-
-
- - {#if user.id === authManager.user.id && canLeave} - - {/if} -
- {/each} -
-
- - {#if sentRequests.length > 0 || receivedRequests.length > 0} -
- {$t('pending')} -
- - - - {#each receivedRequests as request, index (request.id)} -
0}> - {$t('request_received_description')} -
- -
-
- {/each} - - {#each sentRequests as request, index (request.id)} - {@const user = candidates[request.userId]} -
0 || receivedRequests.length > 0}> -
- {#if user} +
+ + + + {#each users as user, index (user.id)} + {@const { ManageAccess } = getPeopleUserActions($t, [ + user, + ...users.filter(({ id }) => id !== authManager.user.id && id !== user.id), + ])} +
0}> +
- {/if} -
-

{user?.name ?? request.userId}

-

{user?.email ?? ''}

+
+

+ {user.name} + {#if user.id === authManager.user.id} + ({$t('you')}) + {/if} +

+

{user.email}

+
+ + {#if user.id === authManager.user.id && canLeave} + + {:else if user.id !== authManager.user.id} + + {/if} +
+ {/each} + + + + {#if sentRequests.length > 0 || receivedRequests.length > 0} +
+ {$t('pending')} +
+ + + + {#each receivedRequests as request, index (request.id)} +
0}> + {$t('request_received_description')} +
+ +
+
+ {/each} + + {#each sentRequests as request, index (request.id)} + {@const user = candidates[request.userId]} +
0 || receivedRequests.length > 0} + > +
+ {#if user} + + {/if} +
+

{user?.name ?? request.userId}

+

{user?.email ?? ''}

+
+
+ + +
+ {/each} +
+
+ {/if} + + + + + + + + + {#if partners.length > 0} + {#each partners as partner (partner.user.id)} +
+
+
+ +
+

+ {partner.user.name} +

+

+ {partner.user.email} +

+
+
+ + {#if partner.sharedByMe} + handleRemovePartner(partner.user)} + icon={mdiClose} + size="small" + aria-label={$t('stop_sharing_photos_with_user')} + /> + {/if}
- +
+ + {#if partner.sharedByMe} +
+ + {$t('shared_with_partner', { values: { partner: partner.user.name } })} + + {$t('partner_can_access', { values: { partner: partner.user.name } })} +
    +
  • + + {$t('partner_can_access_assets')} +
  • +
  • + + {$t('partner_can_access_location')} +
  • +
+ {/if} + + + {#if partner.sharedWithMe} +
+ + {$t('shared_from_partner', { values: { partner: partner.user.name } })} + + + handleShowOnTimelineChanged(partner, isChecked)} + /> + {/if} +
{/each} - - - {/if} + {/if} - - - - -
- -
- {$t('partners')} - - {#if partners.length > 0} - {#each partners as partner (partner.user.id)} -
-
-
- -
-

- {partner.user.name} -

-

- {partner.user.email} -

-
-
- - {#if partner.sharedByMe} - handleRemovePartner(partner.user)} - icon={mdiClose} - size="small" - aria-label={$t('stop_sharing_photos_with_user')} - /> - {/if} -
- -
- - {#if partner.sharedByMe} -
- - {$t('shared_with_partner', { values: { partner: partner.user.name } })} - - {$t('partner_can_access', { values: { partner: partner.user.name } })} -
    -
  • - - {$t('partner_can_access_assets')} -
  • -
  • - - {$t('partner_can_access_location')} -
  • -
- {/if} - - - {#if partner.sharedWithMe} -
- - {$t('shared_from_partner', { values: { partner: partner.user.name } })} - - - handleShowOnTimelineChanged(partner, isChecked)} - /> - {/if} -
+
+
- {/each} - {/if} - -
- +