Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/design-system/src/components/OcAvatars/OcAvatars.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -187,7 +187,7 @@ const getAvatarComponentForItem = (item: Item) => {
return OcAvatarFederated
case 'group':
return OcAvatarGroup
case 'guest':
case 'mail':
return OcAvatarGuest
}
}
Expand Down
3 changes: 0 additions & 3 deletions packages/design-system/src/utils/logger.ts

This file was deleted.

10 changes: 0 additions & 10 deletions packages/design-system/src/utils/shareType.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)')
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<CollaboratorShare>())
;(wrapper.vm as any).selectedCollaborators = [
mock<CollaboratorAutoCompleteItem>({
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<ShareRole>({ id: 'internal-role' })
const externalRole = mock<ShareRole>({ id: 'external-role' })
const { wrapper } = getWrapper({
internalShareRoles: [internalRole],
externalShareRoles: [externalRole]
})
const { addShare } = useSharesStore()
vi.mocked(addShare).mockResolvedValue(mock<CollaboratorShare>())
;(wrapper.vm as any).currentShareRoleType = mock<ShareRoleType>({ id: '2' })
;(wrapper.vm as any).selectedRole = externalRole
;(wrapper.vm as any).selectedCollaborators = [
mock<CollaboratorAutoCompleteItem>({
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', () => {
Expand Down Expand Up @@ -286,6 +368,7 @@ function getWrapper({
users = [],
groups = [],
existingCollaborators = [],
internalShareRoles = [mock<ShareRole>()],
externalShareRoles = [],
user = mock<User>({ id: '1' }),
openXchange = false,
Expand All @@ -296,6 +379,7 @@ function getWrapper({
users?: User[]
groups?: Group[]
existingCollaborators?: CollaboratorShare[]
internalShareRoles?: ShareRole[]
externalShareRoles?: ShareRole[]
user?: User
openXchange?: boolean
Expand Down Expand Up @@ -341,7 +425,7 @@ function getWrapper({
...mocks,
resource,
availableExternalShareRoles: externalShareRoles,
availableInternalShareRoles: [mock<ShareRole>()]
availableInternalShareRoles: internalShareRoles
},
mocks,
stubs: { OcSelect: false, VueSelect: false }
Expand Down
3 changes: 3 additions & 0 deletions packages/web-client/src/helpers/share/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
8 changes: 4 additions & 4 deletions packages/web-client/src/helpers/share/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,19 @@ 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
// created and emailed, never a collaborator share. The value is not used by
// 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)
Expand Down
15 changes: 15 additions & 0 deletions packages/web-client/tests/unit/helpers/share/functions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Permission>({
'@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', () => {
Expand Down
8 changes: 4 additions & 4 deletions packages/web-client/tests/unit/helpers/share/type.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -67,15 +67,15 @@ 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
}
],
[
'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
}
],
Expand Down