From 3af59203b117d225b5caaeb9dd666487a4b6623d Mon Sep 17 00:00:00 2001 From: Benedikt Kulmann Date: Wed, 15 Jul 2026 10:19:24 +0200 Subject: [PATCH 1/2] refactor: delete dead code --- packages/design-system/src/utils/logger.ts | 3 --- packages/design-system/src/utils/shareType.ts | 10 ---------- 2 files changed, 13 deletions(-) delete mode 100644 packages/design-system/src/utils/logger.ts delete mode 100644 packages/design-system/src/utils/shareType.ts diff --git a/packages/design-system/src/utils/logger.ts b/packages/design-system/src/utils/logger.ts deleted file mode 100644 index e8544e731c..0000000000 --- a/packages/design-system/src/utils/logger.ts +++ /dev/null @@ -1,3 +0,0 @@ -const __logger = (v: unknown) => v - -export default __logger diff --git a/packages/design-system/src/utils/shareType.ts b/packages/design-system/src/utils/shareType.ts deleted file mode 100644 index 6f567c3674..0000000000 --- a/packages/design-system/src/utils/shareType.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Share type - */ -export const shareType = { - user: 0, - group: 1, - link: 3, - guest: 4, - remote: 6 -} From 1b6764cc387951de16b953e3b9803d5bb3c2d34a Mon Sep 17 00:00:00 2001 From: Benedikt Kulmann Date: Fri, 17 Jul 2026 16:10:52 +0200 Subject: [PATCH 2/2] feat: guest invites via email address --- .../src/components/OcAvatars/OcAvatars.vue | 4 +- .../InviteCollaboratorForm.vue | 39 +++++++-- .../InviteCollaborator/RecipientContainer.vue | 4 +- .../AutocompleteItem.spec.ts | 2 +- .../InviteCollaboratorForm.spec.ts | 86 ++++++++++++++++++- .../web-client/src/helpers/share/functions.ts | 3 + packages/web-client/src/helpers/share/type.ts | 8 +- .../unit/helpers/share/functions.spec.ts | 15 ++++ .../tests/unit/helpers/share/type.spec.ts | 8 +- 9 files changed, 149 insertions(+), 20 deletions(-) diff --git a/packages/design-system/src/components/OcAvatars/OcAvatars.vue b/packages/design-system/src/components/OcAvatars/OcAvatars.vue index 01a7452799..9a7f220eb4 100644 --- a/packages/design-system/src/components/OcAvatars/OcAvatars.vue +++ b/packages/design-system/src/components/OcAvatars/OcAvatars.vue @@ -55,7 +55,7 @@ import { getTailwindGapClass } from '../../helpers/tailwind' type Item = { displayName?: string name?: string - avatarType?: 'user' | 'link' | 'remote' | 'group' | 'guest' | string + avatarType?: 'user' | 'link' | 'remote' | 'group' | 'mail' | string userName?: string avatar?: string userId?: string @@ -187,7 +187,7 @@ const getAvatarComponentForItem = (item: Item) => { return OcAvatarFederated case 'group': return OcAvatarGroup - case 'guest': + case 'mail': return OcAvatarGuest } } diff --git a/packages/web-app-files/src/components/SideBar/Shares/Collaborators/InviteCollaborator/InviteCollaboratorForm.vue b/packages/web-app-files/src/components/SideBar/Shares/Collaborators/InviteCollaborator/InviteCollaboratorForm.vue index c069519c25..e21e27a025 100644 --- a/packages/web-app-files/src/components/SideBar/Shares/Collaborators/InviteCollaborator/InviteCollaboratorForm.vue +++ b/packages/web-app-files/src/components/SideBar/Shares/Collaborators/InviteCollaborator/InviteCollaboratorForm.vue @@ -157,6 +157,7 @@ import { debounce } from 'lodash-es' import PQueue from 'p-queue' import Mark from 'mark.js' +import * as EmailValidator from 'email-validator' import { storeToRefs } from 'pinia' import AutocompleteItem from './AutocompleteItem.vue' import RoleDropdown from '../RoleDropdown.vue' @@ -357,14 +358,27 @@ const fetchRecipientsTask = useTask(function* (signal, query: string) { })) as CollaboratorAutoCompleteItem[] const isSpace = !unref(resource) || isSpaceResource(unref(resource)) - const guests = isSpace + const contacts = isSpace ? [] : ((yield* call(searchOpenXchangeContacts(query, signal))) as CollaboratorAutoCompleteItem[]) - autocompleteResults.value = [...users, ...groups, ...guests].filter( + const trimmedQuery = (query || '').trim() + // offer the entered value as a guest recipient once it is a valid email address, unless it already + // belongs to a known account (which is suggested as a regular user instead) + const emailBelongsToAccount = users.some((u) => + [u.mail?.toLowerCase(), u.onPremisesSamAccountName?.toLowerCase()].includes( + trimmedQuery.toLowerCase() + ) + ) + const guests: CollaboratorAutoCompleteItem[] = + !emailBelongsToAccount && EmailValidator.validate(trimmedQuery) + ? [{ id: trimmedQuery, displayName: trimmedQuery, shareType: ShareTypes.mail.value }] + : [] + + autocompleteResults.value = [...users, ...groups, ...contacts, ...guests].filter( (collaborator: CollaboratorAutoCompleteItem) => { if (collaborator.id === userStore.user.id) { - // filter current user + // exclude logged-in user return false } @@ -423,7 +437,20 @@ const share = async () => { return } - const type = shareType === ShareTypes.group.value ? 'group' : 'user' + // the group/mail share type keys map 1:1 to the graph recipient type; everything else + // (regular users, federated/remote recipients, unknown types) is invited as a user + const recipientType = [ShareTypes.group.value, ShareTypes.mail.value].includes(shareType) + ? ShareTypes.getByValue(shareType).key + : ShareTypes.user.key + + // guests are internal-style shares and must never receive a federated role, so fall back to + // the first internal role when a guest is invited from the external share mode + // FIXME: clean up internal vs external shares :-( + let roleId = unref(selectedRole).id + if (shareType === ShareTypes.mail.value && unref(isExternalShareRoleType)) { + roleId = unref(availableInternalRoles)[0]?.id + } + savePromises.push( saveQueue.add(async () => { try { @@ -432,12 +459,12 @@ const share = async () => { space: unref(space), resource: unref(resource), options: { - roles: [unref(selectedRole).id], + roles: [roleId], expirationDateTime: unref(expirationDate), recipients: [ { objectId: id, - '@libre.graph.recipient.type': type + '@libre.graph.recipient.type': recipientType } ] } diff --git a/packages/web-app-files/src/components/SideBar/Shares/Collaborators/InviteCollaborator/RecipientContainer.vue b/packages/web-app-files/src/components/SideBar/Shares/Collaborators/InviteCollaborator/RecipientContainer.vue index f9115470b8..207bc40f7c 100644 --- a/packages/web-app-files/src/components/SideBar/Shares/Collaborators/InviteCollaborator/RecipientContainer.vue +++ b/packages/web-app-files/src/components/SideBar/Shares/Collaborators/InviteCollaborator/RecipientContainer.vue @@ -70,9 +70,9 @@ const getRecipientIcon = (): Recipient['icon'] => { label: $gettext('Group') } - case ShareTypes.guest.value: + case ShareTypes.mail.value: return { - name: ShareTypes.guest.icon, + name: ShareTypes.mail.icon, label: $gettext('Guest user') } diff --git a/packages/web-app-files/tests/unit/components/SideBar/Shares/Collaborators/InviteCollaborator/AutocompleteItem.spec.ts b/packages/web-app-files/tests/unit/components/SideBar/Shares/Collaborators/InviteCollaborator/AutocompleteItem.spec.ts index c40e59df31..2677190f34 100644 --- a/packages/web-app-files/tests/unit/components/SideBar/Shares/Collaborators/InviteCollaborator/AutocompleteItem.spec.ts +++ b/packages/web-app-files/tests/unit/components/SideBar/Shares/Collaborators/InviteCollaborator/AutocompleteItem.spec.ts @@ -55,7 +55,7 @@ describe('AutocompleteItem component', () => { } ) it('shows share type for guests', () => { - const { wrapper } = createWrapper({ shareType: ShareTypes.guest.value }) + const { wrapper } = createWrapper({ shareType: ShareTypes.mail.value }) expect(wrapper.find('.files-collaborators-autocomplete-share-type').text()).toEqual('(Guest)') }) }) diff --git a/packages/web-app-files/tests/unit/components/SideBar/Shares/Collaborators/InviteCollaborator/InviteCollaboratorForm.spec.ts b/packages/web-app-files/tests/unit/components/SideBar/Shares/Collaborators/InviteCollaborator/InviteCollaboratorForm.spec.ts index e069611bec..feac8e7d3d 100644 --- a/packages/web-app-files/tests/unit/components/SideBar/Shares/Collaborators/InviteCollaborator/InviteCollaboratorForm.spec.ts +++ b/packages/web-app-files/tests/unit/components/SideBar/Shares/Collaborators/InviteCollaborator/InviteCollaboratorForm.spec.ts @@ -148,6 +148,39 @@ describe('InviteCollaboratorForm', () => { expect(mocks.$clientService.ox.autocompleteContacts).not.toHaveBeenCalled() }) + it('offers the entered value as a guest suggestion when it is a valid email address', async () => { + const { wrapper } = getWrapper({ users: [{ id: '2', mail: 'someone@else.com' } as User] }) + await (wrapper.vm as any).fetchRecipientsTask.perform('guest@example.com') + await flushPromises() + + const guest = (wrapper.vm as any).autocompleteResults.find( + (r: CollaboratorAutoCompleteItem) => r.shareType === ShareTypes.mail.value + ) + expect(guest?.id).toBe('guest@example.com') + expect(guest?.displayName).toBe('guest@example.com') + }) + it('does not offer a guest suggestion for an invalid email', async () => { + const { wrapper } = getWrapper() + await (wrapper.vm as any).fetchRecipientsTask.perform('not-an-email') + await flushPromises() + + expect( + (wrapper.vm as any).autocompleteResults.some( + (r: CollaboratorAutoCompleteItem) => r.shareType === ShareTypes.mail.value + ) + ).toBe(false) + }) + it('does not offer a guest suggestion when the email belongs to a known account', async () => { + const { wrapper } = getWrapper({ users: [{ id: '2', mail: 'guest@example.com' } as User] }) + await (wrapper.vm as any).fetchRecipientsTask.perform('guest@example.com') + await flushPromises() + + expect( + (wrapper.vm as any).autocompleteResults.some( + (r: CollaboratorAutoCompleteItem) => r.shareType === ShareTypes.mail.value + ) + ).toBe(false) + }) }) describe('share action', () => { it('creates a public link and emails the contact for address book contact recipients', async () => { @@ -248,6 +281,55 @@ describe('InviteCollaboratorForm', () => { expect(addShare).toHaveBeenCalled() }) + it('invites a guest as a "mail" recipient with the email as objectId', async () => { + const { wrapper } = getWrapper() + const { addShare } = useSharesStore() + vi.mocked(addShare).mockResolvedValue(mock()) + ;(wrapper.vm as any).selectedCollaborators = [ + mock({ + id: 'guest@example.com', + displayName: 'guest@example.com', + shareType: ShareTypes.mail.value + }) + ] + await wrapper.vm.$nextTick() + await (wrapper.vm as any).share() + + expect(addShare).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + recipients: [{ objectId: 'guest@example.com', '@libre.graph.recipient.type': 'mail' }] + }) + }) + ) + }) + it('assigns an internal role to a guest invited from the external share mode', async () => { + const internalRole = mock({ id: 'internal-role' }) + const externalRole = mock({ id: 'external-role' }) + const { wrapper } = getWrapper({ + internalShareRoles: [internalRole], + externalShareRoles: [externalRole] + }) + const { addShare } = useSharesStore() + vi.mocked(addShare).mockResolvedValue(mock()) + ;(wrapper.vm as any).currentShareRoleType = mock({ id: '2' }) + ;(wrapper.vm as any).selectedRole = externalRole + ;(wrapper.vm as any).selectedCollaborators = [ + mock({ + id: 'guest@example.com', + displayName: 'guest@example.com', + shareType: ShareTypes.mail.value + }) + ] + await wrapper.vm.$nextTick() + await (wrapper.vm as any).share() + + expect(addShare).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ roles: ['internal-role'] }) + }) + ) + }) it.todo('resets focus upon selecting an invitee') }) describe('share role type filter', () => { @@ -286,6 +368,7 @@ function getWrapper({ users = [], groups = [], existingCollaborators = [], + internalShareRoles = [mock()], externalShareRoles = [], user = mock({ id: '1' }), openXchange = false, @@ -296,6 +379,7 @@ function getWrapper({ users?: User[] groups?: Group[] existingCollaborators?: CollaboratorShare[] + internalShareRoles?: ShareRole[] externalShareRoles?: ShareRole[] user?: User openXchange?: boolean @@ -341,7 +425,7 @@ function getWrapper({ ...mocks, resource, availableExternalShareRoles: externalShareRoles, - availableInternalShareRoles: [mock()] + availableInternalShareRoles: internalShareRoles }, mocks, stubs: { OcSelect: false, VueSelect: false } diff --git a/packages/web-client/src/helpers/share/functions.ts b/packages/web-client/src/helpers/share/functions.ts index b2b3909a50..171646b3b6 100644 --- a/packages/web-client/src/helpers/share/functions.ts +++ b/packages/web-client/src/helpers/share/functions.ts @@ -305,6 +305,9 @@ function getShareTypeFromPermission({ link, grantedToV2 }: Permission) { if (grantedToV2?.group) { return ShareTypes.group.value } + if (grantedToV2?.user?.['@libre.graph.userType'] === 'mail') { + return ShareTypes.mail.value + } if (grantedToV2?.user?.['@libre.graph.userType'] === 'Federated') { return ShareTypes.remote.value } diff --git a/packages/web-client/src/helpers/share/type.ts b/packages/web-client/src/helpers/share/type.ts index a904945251..d5a22dd773 100644 --- a/packages/web-client/src/helpers/share/type.ts +++ b/packages/web-client/src/helpers/share/type.ts @@ -39,7 +39,7 @@ export abstract class ShareTypes { static readonly user = new ShareType('user', 0, $gettext('User'), 'user') static readonly group = new ShareType('group', 1, $gettext('Group'), 'group') static readonly link = new ShareType('link', 3, $gettext('Link'), 'link') - static readonly guest = new ShareType('guest', 4, $gettext('Guest'), 'global') + static readonly mail = new ShareType('mail', 4, $gettext('Guest'), 'global') static readonly remote = new ShareType('remote', 6, $gettext('External'), 'earth') // Frontend-only pseudo type: an address book contact (e.g. Open-Xchange) that // is not an OpenCloud user. Such a recipient results in a public link being @@ -47,11 +47,11 @@ export abstract class ShareTypes { // the backend. static readonly contact = new ShareType('contact', 100, $gettext('Contact'), 'global') - static readonly individuals = [this.user, this.guest, this.remote] + static readonly individuals = [this.user, this.mail, this.remote] static readonly collectives = [this.group] static readonly unauthenticated = [this.link] - static readonly authenticated = [this.user, this.group, this.guest, this.remote] - static readonly all = [this.user, this.group, this.link, this.guest, this.remote, this.contact] + static readonly authenticated = [this.user, this.group, this.mail, this.remote] + static readonly all = [this.user, this.group, this.link, this.mail, this.remote, this.contact] static isIndividual(type: ShareType): boolean { return this.individuals.includes(type) diff --git a/packages/web-client/tests/unit/helpers/share/functions.spec.ts b/packages/web-client/tests/unit/helpers/share/functions.spec.ts index 822711c47d..3d5b475898 100644 --- a/packages/web-client/tests/unit/helpers/share/functions.spec.ts +++ b/packages/web-client/tests/unit/helpers/share/functions.spec.ts @@ -276,6 +276,21 @@ describe('share helper functions', () => { expect(result.shareType).toEqual(ShareTypes.remote.value) }) + it('is mail type if grantedToV2 includes a user with the mail user type', () => { + const graphPermission = mock({ + '@libre.graph.permissions.actions': [], + grantedToV2: { user: { '@libre.graph.userType': 'mail' }, group: undefined }, + link: undefined + }) + + const result = buildCollaboratorShare({ + graphPermission, + graphRoles, + resourceId + }) + + expect(result.shareType).toEqual(ShareTypes.mail.value) + }) }) describe('permissions', () => { it('sets permissions if given directly via property', () => { diff --git a/packages/web-client/tests/unit/helpers/share/type.spec.ts b/packages/web-client/tests/unit/helpers/share/type.spec.ts index 2642a21b1c..4218ee9999 100644 --- a/packages/web-client/tests/unit/helpers/share/type.spec.ts +++ b/packages/web-client/tests/unit/helpers/share/type.spec.ts @@ -28,8 +28,8 @@ describe('ShareTypes', () => { [ 'some types', { - types: [ShareTypes.guest, ShareTypes.group], - values: [ShareTypes.guest.value, ShareTypes.group.value] + types: [ShareTypes.mail, ShareTypes.group], + values: [ShareTypes.mail.value, ShareTypes.group.value] } ] ])('with %s', (name: string, { types, values }) => { @@ -67,7 +67,7 @@ describe('ShareTypes', () => { 'given some types and some values without intersection', { types: [ShareTypes.user, ShareTypes.group], - values: [ShareTypes.guest.value, ShareTypes.link.value, ShareTypes.remote.value], + values: [ShareTypes.mail.value, ShareTypes.link.value, ShareTypes.remote.value], result: false } ], @@ -75,7 +75,7 @@ describe('ShareTypes', () => { 'given some types and some values with partial match', { types: [ShareTypes.user, ShareTypes.group], - values: [ShareTypes.guest.value, ShareTypes.group.value], + values: [ShareTypes.mail.value, ShareTypes.group.value], result: true } ],